From f7f77741dc5861267185d2003b6206f3a9d2ea39 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:54:28 -0700 Subject: [PATCH 1/3] feat(cli): start a fresh session at wake when the seat is cold and large (TASK-440) A seat resumed its CLI session on every wake, so a long-lived seat carried a large cold context into each turn. At wake, if the seat has been idle for COMMONLY_SESSION_RECYCLE_IDLE_MINUTES (default 60) or more AND its last session context is COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS (default 128000) or more, start a fresh session with a short restart note. Otherwise resume as before. Context size is read from the claude transcript after each turn and stored with the session entry. Adapters without a transcript report nothing and keep resuming. Memory still reaches a fresh session through the adapter preamble. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- cli/__tests__/session-recycle.test.mjs | 202 +++++++++++++++++++++++++ cli/src/commands/agent.js | 46 +++++- cli/src/lib/adapters/claude.js | 8 + cli/src/lib/session-recycle.js | 126 +++++++++++++++ cli/src/lib/session-store.js | 12 +- 5 files changed, 390 insertions(+), 4 deletions(-) create mode 100644 cli/__tests__/session-recycle.test.mjs create mode 100644 cli/src/lib/session-recycle.js diff --git a/cli/__tests__/session-recycle.test.mjs b/cli/__tests__/session-recycle.test.mjs new file mode 100644 index 0000000000..965b86c9ab --- /dev/null +++ b/cli/__tests__/session-recycle.test.mjs @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, test } from '@jest/globals'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { + RECYCLE_DEFAULTS, + buildRecycleBriefing, + claudeContextTokens, + claudeTranscriptPath, + contextTokensFromTranscript, + idleMinutesSince, + recycleThresholds, + shouldStartFreshSession, +} from '../src/lib/session-recycle.js'; +import { getSessionEntry, setSession } from '../src/lib/session-store.js'; + +const NOW = Date.parse('2026-10-24T04:00:00.000Z'); +const minutesAgo = (minutes) => new Date(NOW - minutes * 60_000).toISOString(); + +const assistantLine = (usage) => JSON.stringify({ + type: 'assistant', + message: { usage }, +}); + +describe('shouldStartFreshSession', () => { + test('idle at the threshold and context at the threshold starts fresh', () => { + expect(shouldStartFreshSession({ + lastTurn: minutesAgo(60), + contextTokens: 128000, + now: NOW, + })).toBe(true); + }); + + test('a recent turn resumes even with a large context', () => { + expect(shouldStartFreshSession({ + lastTurn: minutesAgo(5), + contextTokens: 400000, + now: NOW, + })).toBe(false); + }); + + test('an idle seat with a small context resumes', () => { + expect(shouldStartFreshSession({ + lastTurn: minutesAgo(600), + contextTokens: 20000, + now: NOW, + })).toBe(false); + }); + + test('no measured context means resume, never recycle blind', () => { + expect(shouldStartFreshSession({ + lastTurn: minutesAgo(600), + contextTokens: null, + now: NOW, + })).toBe(false); + }); + + test('no last turn means resume', () => { + expect(shouldStartFreshSession({ + lastTurn: undefined, + contextTokens: 500000, + now: NOW, + })).toBe(false); + }); + + test('thresholds are configurable per install', () => { + const thresholds = { idleMinutes: 10, contextTokens: 1000 }; + expect(shouldStartFreshSession({ + lastTurn: minutesAgo(10), + contextTokens: 1000, + now: NOW, + thresholds, + })).toBe(true); + }); +}); + +describe('recycleThresholds', () => { + test('defaults match the ruling', () => { + expect(recycleThresholds({})).toEqual(RECYCLE_DEFAULTS); + expect(RECYCLE_DEFAULTS).toEqual({ idleMinutes: 60, contextTokens: 128000 }); + }); + + test('environment overrides apply; invalid values fall back to the default', () => { + expect(recycleThresholds({ + COMMONLY_SESSION_RECYCLE_IDLE_MINUTES: '15', + COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS: '64000', + })).toEqual({ idleMinutes: 15, contextTokens: 64000 }); + expect(recycleThresholds({ + COMMONLY_SESSION_RECYCLE_IDLE_MINUTES: '-3', + COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS: 'lots', + })).toEqual(RECYCLE_DEFAULTS); + }); +}); + +describe('idleMinutesSince', () => { + test('whole minutes since the last turn', () => { + expect(idleMinutesSince(minutesAgo(61.5), NOW)).toBe(61); + }); + + test('an unparseable turn time is null', () => { + expect(idleMinutesSince('not a date', NOW)).toBeNull(); + }); +}); + +describe('buildRecycleBriefing', () => { + test('names the restart, the idle time, the context size and the role', () => { + const text = buildRecycleBriefing({ + agentName: 'nova', idleMinutes: 75, contextTokens: 140000, + }); + expect(text).toContain('=== Session restart ==='); + expect(text).toContain('idle 75 min'); + expect(text).toContain('about 140000 context tokens'); + expect(text).toContain('Role: nova.'); + }); +}); + +describe('claudeTranscriptPath', () => { + test('encodes every non-alphanumeric character of the cwd as a dash', () => { + expect(claudeTranscriptPath({ + cwd: '/Users/x/agents/nova.work', + sessionId: 'abc', + configDir: '/cfg', + })).toBe('/cfg/projects/-Users-x-agents-nova-work/abc.jsonl'); + }); + + test('no cwd or no session means no path', () => { + expect(claudeTranscriptPath({ cwd: null, sessionId: 'abc', configDir: '/cfg' })).toBeNull(); + expect(claudeTranscriptPath({ cwd: '/x', sessionId: null, configDir: '/cfg' })).toBeNull(); + }); +}); + +describe('contextTokensFromTranscript', () => { + test('uses the last assistant turn: input plus both cache counts', () => { + const text = [ + assistantLine({ input_tokens: 5, cache_creation_input_tokens: 10, cache_read_input_tokens: 100 }), + JSON.stringify({ type: 'user', message: { content: 'hi' } }), + assistantLine({ input_tokens: 2, cache_creation_input_tokens: 2939, cache_read_input_tokens: 354133 }), + '', + ].join('\n'); + expect(contextTokensFromTranscript(text)).toBe(2 + 2939 + 354133); + }); + + test('a cut first line and malformed lines are skipped', () => { + const text = [ + '{"type":"assis', + 'not json', + assistantLine({ input_tokens: 1, cache_creation_input_tokens: 0, cache_read_input_tokens: 9 }), + ].join('\n'); + expect(contextTokensFromTranscript(text)).toBe(10); + }); + + test('a transcript with no assistant usage is unknown', () => { + expect(contextTokensFromTranscript(JSON.stringify({ type: 'user' }))).toBeNull(); + }); +}); + +describe('claudeContextTokens', () => { + let configDir; + let cwd; + beforeEach(() => { + configDir = mkdtempSync(join(tmpdir(), 'recycle-cfg-')); + cwd = '/work/agent-one'; + }); + afterEach(() => { + rmSync(configDir, { recursive: true, force: true }); + }); + + test('reads the transcript for the session in the encoded cwd folder', () => { + const dir = join(configDir, 'projects', '-work-agent-one'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'sess-1.jsonl'), assistantLine({ + input_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 3, + })); + expect(claudeContextTokens({ cwd, sessionId: 'sess-1', configDir })).toBe(6); + }); + + test('a missing transcript is unknown, so the seat resumes as before', () => { + expect(claudeContextTokens({ cwd, sessionId: 'nope', configDir })).toBeNull(); + }); +}); + +describe('session store contextTokens', () => { + const agent = `recycle-test-${process.pid}-${Date.now()}`; + const pod = 'pod-recycle'; + afterEach(() => { + rmSync(join(process.env.HOME, '.commonly', 'sessions', `${agent}.json`), { force: true }); + }); + + test('setSession stores contextTokens and getSessionEntry returns it', () => { + setSession(agent, pod, 'sid-9', { contextTokens: 150000 }); + const entry = getSessionEntry(agent, pod); + expect(entry.sessionId).toBe('sid-9'); + expect(entry.contextTokens).toBe(150000); + expect(typeof entry.lastTurn).toBe('string'); + }); + + test('setSession without contextTokens stores none', () => { + setSession(agent, pod, 'sid-10'); + expect(getSessionEntry(agent, pod)).not.toHaveProperty('contextTokens'); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 8e9110a533..25e01ae0d3 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -32,11 +32,18 @@ import { startWebhookServer, forwardToLocalWebhook } from '../lib/webhook-server import { getAdapter, listAdapterNames } from '../lib/adapters/index.js'; import { getSession, + getSessionEntry, setSession, clearSessions, wasEventHandled, recordHandledEvent, } from '../lib/session-store.js'; +import { + buildRecycleBriefing, + idleMinutesSince, + recycleThresholds, + shouldStartFreshSession, +} from '../lib/session-recycle.js'; import { readLongTerm, syncBack } from '../lib/memory-bridge.js'; import { pollRetryPolicy } from '../lib/poll-retry.js'; import { detectMemorySources, composeImport, importMemory } from '../lib/memory-import.js'; @@ -1568,7 +1575,26 @@ export const performRun = ({ const runTurn = async ({ event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger, }) => { - const sessionId = getSession(agentName, eventPodId, { binding: sessionBinding }); + // TASK-440: a cold, large session starts fresh at wake instead of being + // resumed. The restart note leads the prompt; memory still reaches the + // fresh session through the adapter's preamble. + const sessionEntry = getSessionEntry(agentName, eventPodId, { binding: sessionBinding }); + let sessionId = getSession(agentName, eventPodId, { binding: sessionBinding }); + let recycleBriefing = null; + if (sessionId && shouldStartFreshSession({ + lastTurn: sessionEntry?.lastTurn, + contextTokens: sessionEntry?.contextTokens, + thresholds: recycleThresholds(), + })) { + recycleBriefing = buildRecycleBriefing({ + agentName, + idleMinutes: idleMinutesSince(sessionEntry.lastTurn), + contextTokens: sessionEntry.contextTokens, + }); + log(`session recycled: idle ${idleMinutesSince(sessionEntry.lastTurn)} min, ` + + `${sessionEntry.contextTokens} context tokens; starting fresh`); + sessionId = null; + } // ADR-005 §Memory bridge: read long_term before spawn, inject via ctx, // and (if the adapter returns a summary) patch-sync back after. const memoryLongTerm = await readLongTerm(client, { onError }); @@ -1613,6 +1639,9 @@ export const performRun = ({ const promptWithSkills = serverSkills.text ? `${focusFrame}\n\n${serverSkills.text}\n\n${prompt}` : promptWithFocus; + const promptForSpawn = recycleBriefing + ? `${recycleBriefing}\n\n${promptWithSkills}` + : promptWithSkills; log(`[${event.type}] spawning ${adapter.name}`); // TASK-102 part B. The CHILD gets a scoped credential; the wrapper keeps @@ -1696,7 +1725,7 @@ export const performRun = ({ effort: seatEnvironment?.effort, }); } - result = await adapter.spawn(frameDecisionForkRule(promptWithSkills), { + result = await adapter.spawn(frameDecisionForkRule(promptForSpawn), { sessionId, cwd: agentCwd, env: commitAttribution?.env || process.env, @@ -1741,7 +1770,18 @@ export const performRun = ({ } if (result.newSessionId) { - setSession(agentName, eventPodId, result.newSessionId, { binding: sessionBinding }); + // TASK-440: record the context size this turn left behind, so the next + // wake can decide whether to recycle. Adapters without a transcript + // report nothing and keep resuming. + const contextTokens = adapter.sessionContextTokens?.({ + cwd: agentCwd, + sessionId: result.newSessionId, + env: process.env, + }) ?? null; + setSession(agentName, eventPodId, result.newSessionId, { + binding: sessionBinding, + contextTokens, + }); } // Decide whether to post the CLI's final text. The agent owns its posting: // if it already spoke this turn through commonly_post_message, echoing its diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index 300d76250f..fa363f878a 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -79,6 +79,7 @@ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir, } from '../mcp-home.js'; import { buildMemoryPreamble } from '../memory-bridge.js'; +import { claudeContextTokens } from '../session-recycle.js'; import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -722,6 +723,13 @@ const prepareArgv = async (innerArgv, ctx) => { export default { name: 'claude', + // TASK-440: context tokens of a session, read from claude's transcript, so + // the run loop can recycle a cold, large session at wake. Null means unknown. + sessionContextTokens: ({ cwd, sessionId, env }) => claudeContextTokens({ + cwd, + sessionId, + configDir: env?.CLAUDE_CONFIG_DIR || undefined, + }), // Identity-bearing runtime tag written into AgentInstallation.config.runtime // by `commonly agent attach`. The CLI command stays `commonly agent attach // claude` (ergonomic) but the persisted runtimeType matches AGENT_TYPES so diff --git a/cli/src/lib/session-recycle.js b/cli/src/lib/session-recycle.js new file mode 100644 index 0000000000..244c25b773 --- /dev/null +++ b/cli/src/lib/session-recycle.js @@ -0,0 +1,126 @@ +/** + * At-wake session recycle (TASK-440). + * + * A seat resumes its CLI session on every wake, so a long-lived seat drags a + * large cold context into each turn. When a wake finds the seat idle long + * enough that the prompt cache is cold AND the last session's context is big + * enough to matter, start a fresh session instead, seeded with a short + * restart note. Otherwise resume exactly as before. + * + * Thresholds are per-install: COMMONLY_SESSION_RECYCLE_IDLE_MINUTES and + * COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS override the defaults below. + * + * Context size is read from the adapter's own transcript. Only the claude + * adapter exposes one today; any adapter without `sessionContextTokens` keeps + * resuming, which is the behaviour before this module existed. + */ + +import { closeSync, existsSync, fstatSync, openSync, readSync } from 'fs'; +import { join } from 'path'; +import { homedir } from 'os'; + +export const RECYCLE_DEFAULTS = Object.freeze({ + idleMinutes: 60, + contextTokens: 128000, +}); + +// Read this many bytes from the end of a transcript. The last assistant +// usage record sits near the end; a full read of a long-lived transcript +// would cost more than the recycle saves. +const TRANSCRIPT_TAIL_BYTES = 4 * 1024 * 1024; + +const positiveNumber = (raw, fallback) => { + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : fallback; +}; + +export const recycleThresholds = (env = process.env) => ({ + idleMinutes: positiveNumber(env.COMMONLY_SESSION_RECYCLE_IDLE_MINUTES, RECYCLE_DEFAULTS.idleMinutes), + contextTokens: positiveNumber(env.COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS, RECYCLE_DEFAULTS.contextTokens), +}); + +// Pure decision. No evidence (no last turn, no measured context) means resume. +export const shouldStartFreshSession = ({ + lastTurn, + contextTokens, + now = Date.now(), + thresholds = RECYCLE_DEFAULTS, +}) => { + if (!lastTurn || !Number.isFinite(contextTokens)) return false; + const last = Date.parse(lastTurn); + if (!Number.isFinite(last)) return false; + const idleMs = now - last; + return idleMs >= thresholds.idleMinutes * 60_000 + && contextTokens >= thresholds.contextTokens; +}; + +export const idleMinutesSince = (lastTurn, now = Date.now()) => { + const last = Date.parse(lastTurn); + return Number.isFinite(last) ? Math.floor((now - last) / 60_000) : null; +}; + +// The restart note. The memory head and the pod focus are NOT repeated here: +// a fresh claude session already receives the full long_term memory through +// its preamble, and the pod focus is already in the wake prompt. +export const buildRecycleBriefing = ({ agentName, idleMinutes, contextTokens }) => [ + '=== Session restart ===', + `This wake starts a fresh session. The previous session was idle ${idleMinutes} min` + + ` with about ${contextTokens} context tokens, so it was not resumed.`, + `Role: ${agentName}. Your saved memory and the pod focus follow below.`, +].join('\n'); + +const defaultClaudeConfigDir = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); + +// Claude Code stores a session at +// /projects//.jsonl +export const claudeTranscriptPath = ({ cwd, sessionId, configDir = defaultClaudeConfigDir() }) => { + if (!cwd || !sessionId) return null; + const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); + return join(configDir, 'projects', encoded, `${sessionId}.jsonl`); +}; + +const readTail = (file) => { + const fd = openSync(file, 'r'); + try { + const size = fstatSync(fd).size; + const length = Math.min(size, TRANSCRIPT_TAIL_BYTES); + const buffer = Buffer.alloc(length); + readSync(fd, buffer, 0, length, size - length); + return buffer.toString('utf8'); + } finally { + closeSync(fd); + } +}; + +// Context tokens of the most recent assistant turn: uncached input plus the +// cache creation and cache read that make up the prompt sent that turn. +export const contextTokensFromTranscript = (text) => { + const lines = text.split('\n'); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index].trim(); + if (!line) continue; + let record; + try { + record = JSON.parse(line); + } catch { + continue; // the first line of a tail read can be cut mid-record + } + const usage = record?.message?.usage; + if (record?.type === 'assistant' && usage && typeof usage === 'object') { + return (usage.input_tokens || 0) + + (usage.cache_creation_input_tokens || 0) + + (usage.cache_read_input_tokens || 0); + } + } + return null; +}; + +export const claudeContextTokens = ({ cwd, sessionId, configDir }) => { + const file = claudeTranscriptPath({ cwd, sessionId, configDir }); + if (!file || !existsSync(file)) return null; + try { + return contextTokensFromTranscript(readTail(file)); + } catch { + return null; + } +}; diff --git a/cli/src/lib/session-store.js b/cli/src/lib/session-store.js index 1e6b147dd2..c3c660ad30 100644 --- a/cli/src/lib/session-store.js +++ b/cli/src/lib/session-store.js @@ -89,6 +89,15 @@ export const getSession = (agentName, podId, { binding = null } = {}) => { return entry?.sessionId || null; }; +// The whole per-pod entry (sessionId, lastTurn, contextTokens), for the +// at-wake recycle decision. getSession stays the resume-only reader. +export const getSessionEntry = (agentName, podId, { binding = null } = {}) => { + if (!agentName || !podId) return null; + const entry = readAgent(agentName)[podId]; + if (!entry || (binding !== null && entry.binding !== binding)) return null; + return entry; +}; + // Read-only diagnostic projection used by the daemon operator surface. A // wrapper may serve several pods, so return the newest completed turn. export const getLastTurn = (agentName) => { @@ -101,13 +110,14 @@ export const getLastTurn = (agentName) => { .at(-1) || null; }; -export const setSession = (agentName, podId, sessionId, { binding = null } = {}) => { +export const setSession = (agentName, podId, sessionId, { binding = null, contextTokens = null } = {}) => { if (!agentName || !podId) return; const state = readAgent(agentName); state[podId] = { sessionId, lastTurn: new Date().toISOString(), ...(binding !== null ? { binding } : {}), + ...(Number.isFinite(contextTokens) ? { contextTokens } : {}), }; writeAgent(agentName, state); }; From d2fd207140aaf86ddef20bf73ec92ecb5858cc48 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:02:52 -0700 Subject: [PATCH 2/3] fix(cli): read the recycle context from the child's own HOME (TASK-440) The wrapper read the transcript from the operator's ~/.claude, but a public seat's claude child runs under an isolated HOME, so the transcript was never found, the context stayed unknown, and the seat resumed forever. The claude adapter now reads the transcript under the env the child ran with (CLAUDE_CONFIG_DIR, else the child HOME) and returns contextTokens on the spawn result. The run loop records what the adapter reports instead of probing the operator home. New tests drive the adapter through the spawn seam with a child HOME and check the reported count, and the no-transcript case. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- .../adapters.claude.context-tokens.test.mjs | 69 +++++++++++++++++++ cli/__tests__/session-recycle.test.mjs | 11 +++ cli/src/commands/agent.js | 11 +-- cli/src/lib/adapters/claude.js | 21 +++--- cli/src/lib/session-recycle.js | 17 +++-- 5 files changed, 105 insertions(+), 24 deletions(-) create mode 100644 cli/__tests__/adapters.claude.context-tokens.test.mjs diff --git a/cli/__tests__/adapters.claude.context-tokens.test.mjs b/cli/__tests__/adapters.claude.context-tokens.test.mjs new file mode 100644 index 0000000000..50eaaa1074 --- /dev/null +++ b/cli/__tests__/adapters.claude.context-tokens.test.mjs @@ -0,0 +1,69 @@ +import { describe, expect, test } from '@jest/globals'; +import { EventEmitter } from 'events'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import claude from '../src/lib/adapters/claude.js'; + +// The child writes its transcript where its own HOME points, and the adapter +// must read there, not in the operator's ~/.claude. A public seat's child runs +// under an isolated HOME, so the operator path would find nothing. +const fakeChild = () => { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = () => {}; + setTimeout(() => { + proc.stdout.emit('data', Buffer.from('ok')); + proc.emit('close', 0); + }, 0); + return proc; +}; + +describe('claude adapter reports the turn context from the child HOME', () => { + test('reads the transcript under the child HOME and returns its token count', async () => { + const childHome = mkdtempSync(join(tmpdir(), 'claude-home-')); + const cwd = join(childHome, 'work'); + try { + const impl = (_cmd, args) => { + const flag = args.includes('--resume') ? '--resume' : '--session-id'; + const sid = args[args.indexOf(flag) + 1]; + const dir = join(childHome, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-')); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}.jsonl`), `${JSON.stringify({ + type: 'assistant', + message: { usage: { input_tokens: 4, cache_creation_input_tokens: 100, cache_read_input_tokens: 2000 } }, + })}\n`); + return fakeChild(); + }; + const res = await claude.spawn('hi', { + sessionId: 'sid-ctx', + cwd, + env: { HOME: childHome }, + memoryLongTerm: '', + _spawnImpl: impl, + }); + expect(res.newSessionId).toBe('sid-ctx'); + expect(res.contextTokens).toBe(2104); + } finally { + rmSync(childHome, { recursive: true, force: true }); + } + }); + + test('a turn with no transcript reports unknown, so the seat keeps resuming', async () => { + const childHome = mkdtempSync(join(tmpdir(), 'claude-home-')); + try { + const res = await claude.spawn('hi', { + sessionId: 'sid-none', + cwd: join(childHome, 'work'), + env: { HOME: childHome }, + memoryLongTerm: '', + _spawnImpl: () => fakeChild(), + }); + expect(res.contextTokens).toBeNull(); + } finally { + rmSync(childHome, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/__tests__/session-recycle.test.mjs b/cli/__tests__/session-recycle.test.mjs index 965b86c9ab..c21b4c4627 100644 --- a/cli/__tests__/session-recycle.test.mjs +++ b/cli/__tests__/session-recycle.test.mjs @@ -6,6 +6,7 @@ import { join } from 'path'; import { RECYCLE_DEFAULTS, buildRecycleBriefing, + claudeConfigDirFor, claudeContextTokens, claudeTranscriptPath, contextTokensFromTranscript, @@ -200,3 +201,13 @@ describe('session store contextTokens', () => { expect(getSessionEntry(agent, pod)).not.toHaveProperty('contextTokens'); }); }); + +describe('claudeConfigDirFor', () => { + test('an explicit CLAUDE_CONFIG_DIR wins', () => { + expect(claudeConfigDirFor({ CLAUDE_CONFIG_DIR: '/cfg', HOME: '/home/x' })).toBe('/cfg'); + }); + + test('otherwise the child HOME, so a public seat looks in its own state', () => { + expect(claudeConfigDirFor({ HOME: '/state/public-1' })).toBe('/state/public-1/.claude'); + }); +}); diff --git a/cli/src/commands/agent.js b/cli/src/commands/agent.js index 25e01ae0d3..28bd3f8247 100644 --- a/cli/src/commands/agent.js +++ b/cli/src/commands/agent.js @@ -1771,16 +1771,11 @@ export const performRun = ({ if (result.newSessionId) { // TASK-440: record the context size this turn left behind, so the next - // wake can decide whether to recycle. Adapters without a transcript - // report nothing and keep resuming. - const contextTokens = adapter.sessionContextTokens?.({ - cwd: agentCwd, - sessionId: result.newSessionId, - env: process.env, - }) ?? null; + // wake can decide whether to recycle. Adapters that report nothing keep + // resuming. setSession(agentName, eventPodId, result.newSessionId, { binding: sessionBinding, - contextTokens, + contextTokens: result.contextTokens ?? null, }); } // Decide whether to post the CLI's final text. The agent owns its posting: diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index fa363f878a..c0eb4697b0 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -79,7 +79,7 @@ import { prepareMcpSpawn, PUBLIC_MCP_UNAVAILABLE_CODE, publicMcpUnavailableError, selectedMcpVersionDir, } from '../mcp-home.js'; import { buildMemoryPreamble } from '../memory-bridge.js'; -import { claudeContextTokens } from '../session-recycle.js'; +import { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js'; import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -723,13 +723,6 @@ const prepareArgv = async (innerArgv, ctx) => { export default { name: 'claude', - // TASK-440: context tokens of a session, read from claude's transcript, so - // the run loop can recycle a cold, large session at wake. Null means unknown. - sessionContextTokens: ({ cwd, sessionId, env }) => claudeContextTokens({ - cwd, - sessionId, - configDir: env?.CLAUDE_CONFIG_DIR || undefined, - }), // Identity-bearing runtime tag written into AgentInstallation.config.runtime // by `commonly agent attach`. The CLI command stays `commonly agent attach // claude` (ergonomic) but the persisted runtimeType matches AGENT_TYPES so @@ -903,7 +896,12 @@ export default { credentials: spawnCredentials(spawnCtx), spawnImpl: ctx._spawnImpl, // test seam only — do not use in production }); - return { text: stdout.trim(), newSessionId: sessionId }; + // TASK-440: the context this turn left, read from the transcript the + // child wrote under its own env (an isolated HOME for public seats). + const contextTokens = claudeContextTokens({ + cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor(env), + }); + return { text: stdout.trim(), newSessionId: sessionId, contextTokens }; } catch (err) { // A public seat's refusal is not a session-store failure, whatever its // message happens to contain: it must reach the run loop, never the @@ -949,7 +947,10 @@ export default { credentials: spawnCredentials(retryCtx), spawnImpl: ctx._spawnImpl, }); - return { text: stdout.trim(), newSessionId: freshId }; + const contextTokens = claudeContextTokens({ + cwd: ctx.cwd, sessionId: freshId, configDir: claudeConfigDirFor(retry.env), + }); + return { text: stdout.trim(), newSessionId: freshId, contextTokens }; } throw err; } finally { diff --git a/cli/src/lib/session-recycle.js b/cli/src/lib/session-recycle.js index 244c25b773..b530a82738 100644 --- a/cli/src/lib/session-recycle.js +++ b/cli/src/lib/session-recycle.js @@ -10,9 +10,9 @@ * Thresholds are per-install: COMMONLY_SESSION_RECYCLE_IDLE_MINUTES and * COMMONLY_SESSION_RECYCLE_CONTEXT_TOKENS override the defaults below. * - * Context size is read from the adapter's own transcript. Only the claude - * adapter exposes one today; any adapter without `sessionContextTokens` keeps - * resuming, which is the behaviour before this module existed. + * Context size is read from the transcript the child CLI wrote, under the env + * the child ran with. Only the claude adapter reports one today; an adapter + * that reports nothing keeps resuming, as before this module existed. */ import { closeSync, existsSync, fstatSync, openSync, readSync } from 'fs'; @@ -69,11 +69,16 @@ export const buildRecycleBriefing = ({ agentName, idleMinutes, contextTokens }) `Role: ${agentName}. Your saved memory and the pod focus follow below.`, ].join('\n'); -const defaultClaudeConfigDir = () => process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'); +// The config dir the CHILD claude process uses. A public seat runs claude +// under an isolated HOME, so reading the operator's ~/.claude would find no +// transcript and the recycle would never fire. +export const claudeConfigDirFor = (env = process.env) => ( + env?.CLAUDE_CONFIG_DIR || join(env?.HOME || homedir(), '.claude') +); // Claude Code stores a session at // /projects//.jsonl -export const claudeTranscriptPath = ({ cwd, sessionId, configDir = defaultClaudeConfigDir() }) => { +export const claudeTranscriptPath = ({ cwd, sessionId, configDir = claudeConfigDirFor() }) => { if (!cwd || !sessionId) return null; const encoded = cwd.replace(/[^a-zA-Z0-9]/g, '-'); return join(configDir, 'projects', encoded, `${sessionId}.jsonl`); @@ -115,7 +120,7 @@ export const contextTokensFromTranscript = (text) => { return null; }; -export const claudeContextTokens = ({ cwd, sessionId, configDir }) => { +export const claudeContextTokens = ({ cwd, sessionId, configDir = claudeConfigDirFor() }) => { const file = claudeTranscriptPath({ cwd, sessionId, configDir }); if (!file || !existsSync(file)) return null; try { From c6dbe4e41fcad73679882b3e0ec33f5369ba4cd9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:05:30 -0700 Subject: [PATCH 3/3] fix(cli): read the recycle context from the bwrap child HOME (TASK-440) Under bwrap the child's HOME is the workspace (wrapArgvWithBwrap's default), and the sanitized env has no HOME, so the transcript probe fell back to the operator home and a cold, large bwrap session recorded no context. prepareArgv now returns the child HOME for the bwrap branch, and the spawn reads the transcript from the HOME the child actually sees. A Linux-only jail test covers it; it is skipped on macOS because bwrap wrapping is Linux-only. Co-Authored-By: Claude Haiku 5.5 Co-authored-by: nova Agent-Model: claude/claude-haiku-5-5/default --- .../adapters.claude.context-tokens.test.mjs | 38 +++++++++++++++++++ cli/src/lib/adapters/claude.js | 12 ++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/cli/__tests__/adapters.claude.context-tokens.test.mjs b/cli/__tests__/adapters.claude.context-tokens.test.mjs index 50eaaa1074..8585369a4a 100644 --- a/cli/__tests__/adapters.claude.context-tokens.test.mjs +++ b/cli/__tests__/adapters.claude.context-tokens.test.mjs @@ -67,3 +67,41 @@ describe('claude adapter reports the turn context from the child HOME', () => { } }); }); + +// bwrap wrapping is Linux-only (wrapArgvWithBwrap throws on macOS), so this +// jail case runs in Linux CI and is skipped on a Mac. +const jailTest = process.platform === 'linux' ? test : test.skip; + +describe('claude adapter reads the transcript inside a bwrap jail', () => { + jailTest('bwrap sets the child HOME to the workspace, and the count is read there', async () => { + const hostHome = mkdtempSync(join(tmpdir(), 'claude-host-')); + const cwd = join(hostHome, 'workspace'); + mkdirSync(cwd, { recursive: true }); + try { + const impl = (cmd, args) => { + const flag = args.includes('--resume') ? '--resume' : '--session-id'; + const sid = args[args.indexOf(flag) + 1]; + const dir = join(cwd, '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-')); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${sid}.jsonl`), `${JSON.stringify({ + type: 'assistant', + message: { usage: { input_tokens: 1, cache_creation_input_tokens: 2, cache_read_input_tokens: 150000 } }, + })}\n`); + return fakeChild(); + }; + const res = await claude.spawn('hi', { + sessionId: 'sid-jail', + cwd, + env: { PATH: process.env.PATH, HOME: hostHome }, + environment: { sandbox: { trust: 'public', mode: 'bwrap' } }, + memoryLongTerm: '', + _spawnImpl: impl, + _detectBwrap: () => ({ available: true, path: 'bwrap' }), + }); + expect(res.newSessionId).toBe('sid-jail'); + expect(res.contextTokens).toBe(150003); + } finally { + rmSync(hostHome, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/src/lib/adapters/claude.js b/cli/src/lib/adapters/claude.js index c0eb4697b0..fed3975257 100644 --- a/cli/src/lib/adapters/claude.js +++ b/cli/src/lib/adapters/claude.js @@ -716,7 +716,10 @@ const prepareArgv = async (innerArgv, ctx) => { workspacePath: ctx.cwd, readOnlyPaths: [ctx.mcpConfigDir, commitHooksPath, originalHooksPath, ...(ctx.mcpReadDirectories || [])].filter(Boolean), }); - return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv }; + // bwrap sets the child's HOME to the workspace (wrapArgvWithBwrap's + // default), so the child's transcript lands under the workspace, not the + // HOME the host process sees. TASK-440 reads from here. + return { cmd: wrapped[0], args: wrapped.slice(1), env: claudeEnv, childHome: ctx.cwd }; } return { cmd: 'claude', args: innerArgv, env: claudeEnv }; }; @@ -882,7 +885,7 @@ export default { publicSkillArgs, claudeEnv, }; - const { cmd, args, env } = await prepareArgv(baseArgs, spawnCtx); + const { cmd, args, env, childHome } = await prepareArgv(baseArgs, spawnCtx); const stdout = await runClaude({ cmd, args, @@ -899,7 +902,7 @@ export default { // TASK-440: the context this turn left, read from the transcript the // child wrote under its own env (an isolated HOME for public seats). const contextTokens = claudeContextTokens({ - cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor(env), + cwd: ctx.cwd, sessionId, configDir: claudeConfigDirFor({ ...env, HOME: childHome || env?.HOME }), }); return { text: stdout.trim(), newSessionId: sessionId, contextTokens }; } catch (err) { @@ -948,7 +951,8 @@ export default { spawnImpl: ctx._spawnImpl, }); const contextTokens = claudeContextTokens({ - cwd: ctx.cwd, sessionId: freshId, configDir: claudeConfigDirFor(retry.env), + cwd: ctx.cwd, sessionId: freshId, + configDir: claudeConfigDirFor({ ...retry.env, HOME: retry.childHome || retry.env?.HOME }), }); return { text: stdout.trim(), newSessionId: freshId, contextTokens }; }