Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/core/command-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/core/cost-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/core/dashboard-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ function sessionTokenUsage(s: Session, workingDir?: string): SessionTokenUsage |
sessionId: s.sessionId,
cliSessionId: s.cliSessionId,
cwd: workingDir ?? s.workingDir,
larkAppId: s.larkAppId,
});
}

Expand Down
4 changes: 3 additions & 1 deletion src/services/insight/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,6 @@ export interface SafeInsightOverview {
export interface InsightOverviewSessionInput extends InsightReportQuery {
title?: string;
botName?: string;
larkAppId?: string;
workingDir?: string;
status?: string;
lastMessageAt?: number;
Expand Down Expand Up @@ -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 {
Expand Down
59 changes: 54 additions & 5 deletions src/services/transcript-resolver.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
* `<botmuxHome>/bots/<appId>/claude` instead of the global data dir. */
larkAppId?: string;
/** Bypass a cached miss for lazily-created transcripts. */
fresh?: boolean;
}
Expand Down Expand Up @@ -96,20 +101,64 @@ function claudeForkDataDir(cliId: 'seed' | 'relay'): string {
return dir;
}

/** The redirected Claude data dir of a sandboxed bot: `<botmuxHome>/bots/<appId>/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': {
Expand Down
1 change: 1 addition & 0 deletions src/services/usage-ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ function ledgerArgsForDaemonSession(ds: DaemonSession): Omit<RecordSessionUsageA
sessionId: s.sessionId,
cliSessionId: s.cliSessionId,
cwd: workingDir,
larkAppId: ds.larkAppId ?? s.larkAppId,
fresh: true,
});
return {
Expand Down
146 changes: 146 additions & 0 deletions test/transcript-resolver-bot-home.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, utimesSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';

// Regression: sandboxed (CLI-data-redirected) bots run Claude with
// CLAUDE_CONFIG_DIR=<botmuxHome>/bots/<appId>/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<typeof import('node:os')>()),
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 = <base>/.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 <botmuxHome>/bots/<appId>/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' });
});
});
1 change: 1 addition & 0 deletions test/usage-ledger.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down