diff --git a/src/core/command-handler.ts b/src/core/command-handler.ts index d188c1489..a9bd09af1 100644 --- a/src/core/command-handler.ts +++ b/src/core/command-handler.ts @@ -1313,6 +1313,7 @@ export async function handleCommand( sessionId: ds.session.sessionId, cliSessionId: ds.session.cliSessionId, cwd: ds.session.workingDir, + larkAppId: ds.larkAppId ?? ds.session.larkAppId, }, { detail: 'summary' }); await sessionReply(rootId, formatInsightCard(report, loc)); break; diff --git a/src/core/cost-calculator.ts b/src/core/cost-calculator.ts index 76058df3b..df4d189b7 100644 --- a/src/core/cost-calculator.ts +++ b/src/core/cost-calculator.ts @@ -33,6 +33,9 @@ export interface SessionTokenUsageQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id — lets the transcript resolver find sandboxed + * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ + larkAppId?: string; /** Bypass the reparse throttle (stat short-circuit and incremental folding * still apply). Use at low-frequency exact points like ledger snapshots. */ fresh?: boolean; diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index 2f22e5f4c..8ea5e4193 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -1083,6 +1083,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight', (req, res, params) => { sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, { offset, limit, @@ -1101,6 +1102,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight', (req, res, params) => { sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, { detail }); jsonRes(res, 200, { ok: true, report }); } catch (err: any) { @@ -1120,6 +1122,7 @@ ipcRoute('GET', '/api/sessions/:sessionId/insight/turn/:turnIndex', (req, res, p sessionId: session.sessionId, cliSessionId: session.cliSessionId, cwd: session.workingDir, + larkAppId: session.larkAppId, }, parseInt(params.turnIndex, 10) || 0, { offset, limit }); jsonRes(res, 200, { ok: true, turn }); } catch (err: any) { diff --git a/src/core/dashboard-rows.ts b/src/core/dashboard-rows.ts index c5d1d0312..d529f5936 100644 --- a/src/core/dashboard-rows.ts +++ b/src/core/dashboard-rows.ts @@ -111,6 +111,7 @@ function sessionTokenUsage(s: Session, workingDir?: string): SessionTokenUsage | sessionId: s.sessionId, cliSessionId: s.cliSessionId, cwd: workingDir ?? s.workingDir, + larkAppId: s.larkAppId, }); } diff --git a/src/services/insight/types.ts b/src/services/insight/types.ts index 09a26fcd9..e2c8927c5 100644 --- a/src/services/insight/types.ts +++ b/src/services/insight/types.ts @@ -166,7 +166,6 @@ export interface SafeInsightOverview { export interface InsightOverviewSessionInput extends InsightReportQuery { title?: string; botName?: string; - larkAppId?: string; workingDir?: string; status?: string; lastMessageAt?: number; @@ -419,6 +418,9 @@ export interface InsightReportQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id — lets the transcript resolver find sandboxed + * (CLI-data-redirected) bots' transcripts under BOT_HOME. */ + larkAppId?: string; } export interface RawInsightSpan { diff --git a/src/services/transcript-resolver.ts b/src/services/transcript-resolver.ts index a9de9426c..4b748bd41 100644 --- a/src/services/transcript-resolver.ts +++ b/src/services/transcript-resolver.ts @@ -1,7 +1,8 @@ -import { existsSync, realpathSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { existsSync, realpathSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { CliId } from '../adapters/cli/types.js'; +import { botHomePath } from '../adapters/cli/read-isolation.js'; import { createCliAdapterSync } from '../adapters/cli/registry.js'; import { expandHome } from '../core/working-dir.js'; import { findCodexRolloutBySessionId, findCodexSessionIdByBotmuxSessionId } from './codex-transcript.js'; @@ -16,6 +17,10 @@ export interface TranscriptPathQuery { sessionId: string; cliSessionId?: string; cwd?: string; + /** Owning bot's Lark app id. Enables the BOT_HOME fallback for sandboxed + * (CLI-data-redirected) bots whose transcripts live under + * `/bots//claude` instead of the global data dir. */ + larkAppId?: string; /** Bypass a cached miss for lazily-created transcripts. */ fresh?: boolean; } @@ -96,20 +101,64 @@ function claudeForkDataDir(cliId: 'seed' | 'relay'): string { return dir; } +/** The redirected Claude data dir of a sandboxed bot: `/bots//claude`. + * Sandboxed bots with CLI-data redirect get CLAUDE_CONFIG_DIR pointed here by the + * worker, so their transcripts never appear under the global data dir and every + * daemon-side reader (dashboard token column, usage ledger, insight) must fall + * back to this dir on a global miss. botmuxHome is derived exactly like the + * worker derives BOT_HOME (`dirname(SESSION_DATA_DIR)`); no SESSION_DATA_DIR + * means no redirect ever happened, so no fallback. Deliberately existence-driven + * rather than re-deriving the redirect decision (sandbox × adapter capability × + * wrapper): probing global-then-BOT_HOME is correct in every combination and + * can't drift from worker.ts. */ +function botHomeClaudeDataDir(larkAppId: string | undefined): string | null { + if (!larkAppId) return null; + const sessionDataDir = process.env.SESSION_DATA_DIR; + if (!sessionDataDir) return null; + try { + return join(botHomePath(dirname(sessionDataDir), larkAppId), 'claude'); + } catch { + return null; // unsafe app id — never build a path from it + } +} + +function claudeJsonlWithBotHomeFallback(sid: string, q: TranscriptPathQuery, primaryDataDir: string): string | null { + if (!q.cwd) return null; + const globalPath = getClaudeSessionJsonlPath(sid, q.cwd, primaryDataDir); + const botHomeDir = botHomeClaudeDataDir(q.larkAppId); + const botHomeJsonl = botHomeDir ? getClaudeSessionJsonlPath(sid, q.cwd, botHomeDir) : null; + // Both exist when a persistent session straddles a sandbox flip (the CLI kept + // its session id but moved data dirs — either direction). The stale copy stops + // growing while the live one keeps its mtime fresh, so newest-wins tracks the + // file the CLI is actually writing; a fixed preference would freeze usage at + // the flip point forever. + if (globalPath && botHomeJsonl) return newerFile(globalPath, botHomeJsonl); + return globalPath ?? botHomeJsonl; +} + +/** Ties (e.g. a byte-identical copy) keep `a` — the global/stock path. */ +function newerFile(a: string, b: string): string { + try { + return statSync(b).mtimeMs > statSync(a).mtimeMs ? b : a; + } catch { + return a; + } +} + export function resolveSessionTranscriptPath(q: TranscriptPathQuery): ResolvedTranscriptPath | null { const sid = q.cliSessionId || q.sessionId; switch (q.cliId) { case 'claude-code': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, join(homedir(), '.claude')) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, join(homedir(), '.claude')); return path ? { path, kind: 'claude' } : null; } case 'aiden': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, join(homedir(), '.claude')) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, join(homedir(), '.claude')); return path ? { path, kind: 'claude' } : null; } case 'seed': case 'relay': { - const path = q.cwd ? getClaudeSessionJsonlPath(sid, q.cwd, claudeForkDataDir(q.cliId)) : null; + const path = claudeJsonlWithBotHomeFallback(sid, q, claudeForkDataDir(q.cliId)); return path ? { path, kind: 'claude' } : null; } case 'codex': { diff --git a/src/services/usage-ledger.ts b/src/services/usage-ledger.ts index 69b5544e7..e4de7f08f 100644 --- a/src/services/usage-ledger.ts +++ b/src/services/usage-ledger.ts @@ -451,6 +451,7 @@ function ledgerArgsForDaemonSession(ds: DaemonSession): Omit/bots//claude, so their transcripts never +// appear under the global ~/.claude. Daemon-side readers (dashboard token +// column, usage ledger, insight) resolved ONLY against the global dir → token +// usage silently showed "-" for every sandboxed bot. The resolver must fall +// back to the BOT_HOME dir when the query carries the owning bot's app id. + +// Point homedir at a controllable fake so the "global" ~/.claude is test-owned. +// vi.hoisted: the mock factory runs at import time, before module-level lets. +const fake = vi.hoisted(() => ({ home: '/nonexistent-home' })); +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal()), + homedir: () => fake.home, +})); + +import { resolveSessionTranscriptPath } from '../src/services/transcript-resolver.js'; + +const APP_ID = 'cli_testbot0001'; + +function projectKey(cwd: string): string { + return realpathSync(cwd).replace(/[^A-Za-z0-9-]/g, '-'); +} + +describe('resolveSessionTranscriptPath — sandboxed-bot BOT_HOME fallback', () => { + const trash: string[] = []; + let base: string; + let cwd: string; + let savedSessionDataDir: string | undefined; + + beforeEach(() => { + savedSessionDataDir = process.env.SESSION_DATA_DIR; + base = mkdtempSync(join(tmpdir(), 'botmux-bot-home-')); + trash.push(base); + fake.home = join(base, 'home'); + cwd = join(base, 'work'); + mkdirSync(cwd, { recursive: true }); + // botmuxHome = /.botmux, exactly like ~/.botmux with data dir inside. + process.env.SESSION_DATA_DIR = join(base, '.botmux', 'data'); + }); + + afterEach(() => { + if (savedSessionDataDir === undefined) delete process.env.SESSION_DATA_DIR; + else process.env.SESSION_DATA_DIR = savedSessionDataDir; + for (const d of trash.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + + function writeBotHomeTranscript(sid: string): string { + const dir = join(base, '.botmux', 'bots', APP_ID, 'claude', 'projects', projectKey(cwd)); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + function writeGlobalTranscript(sid: string): string { + const dir = join(fake.home, '.claude', 'projects', projectKey(cwd)); + mkdirSync(dir, { recursive: true }); + const p = join(dir, `${sid}.jsonl`); + writeFileSync(p, '{}'); + return p; + } + + it('falls back to /bots//claude when the global dir misses', () => { + const expected = writeBotHomeTranscript('sb-1'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-1', cwd, larkAppId: APP_ID, + }); + expect(resolved).toEqual({ path: expected, kind: 'claude' }); + }); + + it('resolves the global dir when only it has the transcript (non-redirected bot)', () => { + const global = writeGlobalTranscript('sb-2'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-2', cwd, larkAppId: APP_ID, + }); + expect(resolved?.path).toBe(global); + }); + + // A persistent session that straddles a sandbox flip keeps its session id but + // moves data dirs — the stale copy stops growing, the live one stays fresh. + it('picks the newer file when both dirs have the transcript (sandbox flipped ON)', () => { + const global = writeGlobalTranscript('sb-flip'); + const botHome = writeBotHomeTranscript('sb-flip'); + utimesSync(global, new Date('2026-01-01'), new Date('2026-01-01')); + utimesSync(botHome, new Date('2026-01-02'), new Date('2026-01-02')); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-flip', cwd, larkAppId: APP_ID, + })?.path).toBe(botHome); + }); + + it('picks the newer file when both dirs have the transcript (sandbox flipped OFF)', () => { + const global = writeGlobalTranscript('sb-flop'); + const botHome = writeBotHomeTranscript('sb-flop'); + utimesSync(global, new Date('2026-01-02'), new Date('2026-01-02')); + utimesSync(botHome, new Date('2026-01-01'), new Date('2026-01-01')); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-flop', cwd, larkAppId: APP_ID, + })?.path).toBe(global); + }); + + it('keeps the global path on an exact mtime tie (byte-identical copy)', () => { + const global = writeGlobalTranscript('sb-tie'); + const botHome = writeBotHomeTranscript('sb-tie'); + const t = new Date('2026-01-01'); + utimesSync(global, t, t); + utimesSync(botHome, t, t); + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-tie', cwd, larkAppId: APP_ID, + })?.path).toBe(global); + }); + + it('returns null without larkAppId (no fallback target)', () => { + writeBotHomeTranscript('sb-3'); + expect(resolveSessionTranscriptPath({ cliId: 'claude-code', sessionId: 'sb-3', cwd })).toBeNull(); + }); + + it('returns null without SESSION_DATA_DIR (no redirect ever happened)', () => { + writeBotHomeTranscript('sb-4'); + delete process.env.SESSION_DATA_DIR; + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-4', cwd, larkAppId: APP_ID, + })).toBeNull(); + }); + + it('never builds a path from an unsafe app id (returns null instead of throwing)', () => { + writeBotHomeTranscript('sb-5'); + for (const evil of ['../evil', 'a/b', '..', '']) { + expect(resolveSessionTranscriptPath({ + cliId: 'claude-code', sessionId: 'sb-5', cwd, larkAppId: evil, + })).toBeNull(); + } + }); + + it('applies the same fallback for aiden claude-format transcripts', () => { + const expected = writeBotHomeTranscript('sb-6'); + const resolved = resolveSessionTranscriptPath({ + cliId: 'aiden', sessionId: 'sb-6', cwd, larkAppId: APP_ID, + }); + expect(resolved).toEqual({ path: expected, kind: 'claude' }); + }); +}); diff --git a/test/usage-ledger.test.ts b/test/usage-ledger.test.ts index 00ebfc78a..fd30beec5 100644 --- a/test/usage-ledger.test.ts +++ b/test/usage-ledger.test.ts @@ -599,6 +599,7 @@ describe('daemon-session wrappers', () => { sessionId: 'sess-1', cliSessionId: 'cli-sess-1', cwd: '/live-repo', + larkAppId: 'cli_app', fresh: true, }); expect(rec).toMatchObject({