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..8585369a4a --- /dev/null +++ b/cli/__tests__/adapters.claude.context-tokens.test.mjs @@ -0,0 +1,107 @@ +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 }); + } + }); +}); + +// 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/__tests__/session-recycle.test.mjs b/cli/__tests__/session-recycle.test.mjs new file mode 100644 index 0000000000..c21b4c4627 --- /dev/null +++ b/cli/__tests__/session-recycle.test.mjs @@ -0,0 +1,213 @@ +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, + claudeConfigDirFor, + 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'); + }); +}); + +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 8e9110a533..28bd3f8247 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,13 @@ 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 that report nothing keep + // resuming. + setSession(agentName, eventPodId, result.newSessionId, { + binding: sessionBinding, + contextTokens: result.contextTokens ?? null, + }); } // 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..fed3975257 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 { claudeConfigDirFor, claudeContextTokens } from '../session-recycle.js'; import { skillsAsText } from '../skill-prompt.js'; import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; @@ -715,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 }; }; @@ -881,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, @@ -895,7 +899,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, HOME: childHome || env?.HOME }), + }); + 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 @@ -941,7 +950,11 @@ 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, HOME: retry.childHome || retry.env?.HOME }), + }); + 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 new file mode 100644 index 0000000000..b530a82738 --- /dev/null +++ b/cli/src/lib/session-recycle.js @@ -0,0 +1,131 @@ +/** + * 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 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'; +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'); + +// 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 = claudeConfigDirFor() }) => { + 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 = claudeConfigDirFor() }) => { + 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); };