/** * Opt-in SDK session resume for the claude-code engine — the pure half (decision + prompt building), * kept free of SDK/IO wiring so every fallback reason is unit-testable. * * Fresh turn (today's path): ONE user message = contextBlock + + new message, so * the whole history is re-written to prompt cache every turn. Resume turn: `resume: ` * and send only the new message; the stable context went in once on the session's fresh query, and * whatever CHANGED since (context sections, messages other channels appended) plus the current speaker's * `user` section rides AFTER the user text so the cached transcript prefix stays byte-identical. Only one * speaker's turns ever share a CC session (speaker-change). * * Flag: `directives.resume` (per conversation: `[resume:on]` or PUT /api/sessions/:id/directives) over * `agent-config.json` `sdkResume` (global, re-read every turn). Default OFF. */ import { createHash } from 'node:crypto'; import { existsSync, readdirSync } from 'node:fs'; import { homedir } from 'node:os'; import path from 'node:path'; import type { ConvMessage } from '../sessions.ts'; import type { Directives } from '../directives.ts'; import type { AgentSettings } from '../shraga-config.ts'; /** Persisted on SessionMeta.claudeResume — the facts needed to decide whether the stored CC session * may be resumed. Hashes, not content: this lives in the 9 MB sessions index. */ export interface ClaudeResumeState { /** Claude Code session id (SDK init/result `session_id`); resume keeps it stable across turns. */ claudeSessionId: string; /** Hash of the CLAUDE_CONFIG_DIR the transcript was written under (per-user login or box default). */ configDirHash: string; /** speakerKey() of the person whose turns this CC session holds. Another speaker never resumes it: the * transcript carries this speaker's private user context (learnings/corrections). */ speaker?: string; /** Model the last turn resolved (informational — CC resumes across models; only the cache is per-model). */ model?: string; /** When this CC session was started (first fresh query). */ startedAt: number; /** Id of the last conversation message at the start of the last turn; messages after it are what CC hasn't seen. */ markId?: string; /** Identity of the newest shraga summary / compact marker when the state was saved. */ summaryKey: string; /** Section name → hash of the context CC has already been given. */ sections: Record; /** Set by the core when another engine ran a turn on this session (its turns aren't in the CC transcript). */ interruptedBy?: string; } export type TurnPath = 'resume' | 'fresh' | `fallback:${string}`; /** Unseen out-of-band text above this size means resume would re-send a history anyway — go fresh. */ export const MAX_UNSEEN_CHARS = 30_000; export function isResumeEnabled(directives: Directives, config: AgentSettings): boolean { // The per-session directives endpoint stores passthrough values opaquely, so accept string forms too. const v = (directives.resume ?? config.sdkResume) as unknown; return v === true || v === 'on' || v === 'true'; } export function shortHash(text: string): string { return createHash('sha256').update(text).digest('hex').slice(0, 16); } /** Who is speaking, as a hash (the sessions index must not gain raw emails). */ export function speakerKey(uid: string, email?: string): string { return shortHash(`${uid}\n${email?.trim().toLowerCase() ?? ''}`); } /** Errors that mean THIS resume can't run (the stored transcript is gone/unreadable) — worth one fresh retry. * Anything else (quota, auth, API, process crash) would fail a fresh query the same way: surface it as-is. */ export function isResumeFailure(text: string): boolean { return /No conversation found|\b(session|transcript)\b.{0,60}\b(not found|missing|corrupt|invalid)\b/i.test(text); } /** Where the CLI keeps transcripts for a run: the routed per-user login, else the process's config dir. */ export function claudeConfigDir(accountDir: string | null | undefined): string { return accountDir || process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(homedir(), '.claude'); } /** `/projects//.jsonl`, found by scan rather than by re-deriving the CLI's slug * rule (a wrong guess would silently turn every resume into a fallback). */ export function findClaudeTranscript(sessionId: string, configDir: string): string | null { const projects = path.join(configDir, 'projects'); if (!existsSync(projects)) return null; for (const dir of readdirSync(projects, { withFileTypes: true })) { if (!dir.isDirectory()) continue; const file = path.join(projects, dir.name, `${sessionId}.jsonl`); if (existsSync(file)) return file; } return null; } /** One conversation message as history text — shared by the fresh history prompt and the resume tail. */ export function renderConvMessage(m: ConvMessage): string | null { const texts = m.blocks .filter((b) => b.type === 'text' || b.type === 'context') .map((b) => (b.type === 'context' ? `[${b.label}]: ${b.text}` : (b as { text: string }).text)) .filter(Boolean); return texts.length ? `${m.role === 'user' ? 'User' : 'Assistant'}: ${texts.join('\n')}` : null; } /** Changes whenever maybeCompact writes a summary or /compact adds a marker (applyCompactMarkers * turns the latter into a synthetic leading message). */ export function conversationSummaryKey(conv: ConvMessage[]): string { for (let i = conv.length - 1; i >= 0; i--) { const s = conv[i].blocks.find((b) => b.type === 'summary') as { compactedCount: number } | undefined; if (s) return `summary:${s.compactedCount}`; } const first = conv[0]; if (first?.id === 'compact-summary') return `marker:${shortHash(renderConvMessage(first) ?? '')}`; return ''; } /** * Messages CC has not seen: everything after the mark, minus the previous turn's own reply (the first * message, when it is the assistant's) and this turn's prompt (the last, when it is a user message — * every channel appends it before streaming). null = the mark is gone (history was rewritten). */ export function unseenMessages(conv: ConvMessage[], markId: string | undefined): ConvMessage[] | null { if (!markId) return null; const idx = conv.findLastIndex((m) => m.id === markId); if (idx < 0) return null; const after = conv.slice(idx + 1); if (after[0]?.role === 'assistant') after.shift(); if (after.at(-1)?.role === 'user') after.pop(); return after; } export interface TurnDecisionInput { enabled: boolean; state?: ClaudeResumeState; conversation: ConvMessage[]; configDirHash: string; speaker: string; /** A CLI process from an earlier run on this session is still alive (e.g. the run a steer took over). */ cliAlive?: boolean; conversationReset?: boolean; hasTranscript: (claudeSessionId: string) => boolean; } export function decideClaudeTurn(i: TurnDecisionInput): { path: TurnPath; unseen: ConvMessage[] } { const fallback = (reason: string) => ({ path: `fallback:${reason}` as TurnPath, unseen: [] }); if (!i.enabled) return { path: 'fresh', unseen: [] }; const s = i.state; if (!s?.claudeSessionId) return fallback('no-session'); // Two CLI processes appending to one transcript can interleave it; the takeover turn starts its own. if (i.cliAlive) return fallback('concurrent-run'); if (s.speaker !== i.speaker) return fallback('speaker-change'); if (s.interruptedBy) return fallback('engine-switch'); if (s.configDirHash !== i.configDirHash) return fallback('account-change'); if (i.conversationReset) return fallback('reset'); if (s.summaryKey !== conversationSummaryKey(i.conversation)) return fallback('summary'); const unseen = unseenMessages(i.conversation, s.markId); if (!unseen) return fallback('history-diverged'); if (unseen.reduce((n, m) => n + (renderConvMessage(m)?.length ?? 0), 0) > MAX_UNSEEN_CHARS) return fallback('drift'); // Checked last (filesystem): the CLI's periodic cleanup (cleanupPeriodDays) or a moved config dir // deletes transcripts under sessions we still hold — catch it before spawning a doomed resume. if (!i.hasTranscript(s.claudeSessionId)) return fallback('transcript-missing'); return { path: 'resume', unseen }; } export function sectionHashes(sections: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(sections)) if (v) out[k] = shortHash(v); return out; } /** Re-sent on every resume turn regardless of hashes: who is speaking (identity + role) must never depend on * bookkeeping about what an earlier, possibly cancelled, turn managed to deliver. Small. */ export const ALWAYS_SENT_SECTIONS = ['user']; /** Hash marking a section whose delivery is unknown — never equals a real hash, so the next turn re-sends it. */ const UNKNOWN = '?'; /** * Section hashes to store the moment a resume prompt is SUBMITTED. The CLI writes the prompt to the * transcript before any output, so a turn cancelled early may or may not have delivered its delta. Unchanged * sections are known either way; changed, new and removed ones are marked unknown and re-sent next turn. */ export function sectionsAfterSubmit(prev: Record, next: Record): Record { const out: Record = {}; for (const k of new Set([...Object.keys(prev), ...Object.keys(next)])) out[k] = prev[k] === next[k] ? prev[k] : UNKNOWN; return out; } /** The context sections that changed since CC last saw them (new or edited) plus ALWAYS_SENT_SECTIONS, and a * note for sections that no longer apply. '' when there is nothing to send. */ export function buildContextDelta(prev: Record, sections: Record): string { const changed = Object.entries(sections).filter(([k, v]) => v && (ALWAYS_SENT_SECTIONS.includes(k) || prev[k] !== shortHash(v))); const removed = Object.keys(prev).filter((k) => !sections[k]); if (!changed.length && !removed.length) return ''; return [ '', 'Current context for this turn. Each section below replaces its earlier version.', ...changed.map(([k, v]) => `
\n${v}\n
`), ...(removed.length ? [`No longer applicable: ${removed.join(', ')}`] : []), '
', ].join('\n'); } export function buildResumePrompt(prompt: string, unseen: ConvMessage[], delta: string): string { const lines = unseen.map(renderConvMessage).filter(Boolean); const unseenBlock = lines.length ? `\nAdded to this conversation since your last reply (other participants, channel context, notices):\n${lines.join('\n\n')}\n` : ''; return [prompt, unseenBlock, delta].filter(Boolean).join('\n\n'); }