shraga 0.1.111 → 0.1.112

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,7 +13,7 @@
13
13
  <link rel="preconnect" href="https://fonts.googleapis.com" />
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
15
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
16
- <script type="module" crossorigin src="/assets/index-BNAh4GUs.js"></script>
16
+ <script type="module" crossorigin src="/assets/index-Dc1ljSt3.js"></script>
17
17
  <link rel="stylesheet" crossorigin href="/assets/index-DIMte_k6.css">
18
18
  </head>
19
19
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shraga",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -3,7 +3,7 @@ import { summarizeText } from './summarize.ts';
3
3
  import { dataSync } from './data-sync.ts';
4
4
  import type { McpConfig } from './mcp.ts';
5
5
  import type { ClaudeAccountRef } from './claude-account.ts';
6
- import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, setClaudeResume, type ConvMessage, type ConvBlock } from './sessions.ts';
7
7
  import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
8
8
  import {
9
9
  resolveDefaultSkillsContent,
@@ -324,7 +324,10 @@ export async function* streamChat(opts: {
324
324
  const stickyNames = opts.sessionId ? getSession(opts.sessionId)?.triggeredSkills ?? [] : [];
325
325
  const newTriggerNames = discoveryEnabled ? matchTriggeredSkillNames(effectivePrompt, opts.context) : [];
326
326
  const triggeredNames = [...new Set([...stickyNames, ...newTriggerNames])];
327
- const triggeredSkills = skillInjectionBlocks(triggeredNames);
327
+ // Per-name blocks so an engine that resumes across turns can send only newly triggered ones. Joined
328
+ // exactly as skillInjectionBlocks(triggeredNames) joins them, so contextBlock is byte-identical.
329
+ const triggeredSkillBlocks = triggeredNames.map((n) => [n, skillInjectionBlocks([n])] as const).filter(([, b]) => b);
330
+ const triggeredSkills = triggeredSkillBlocks.map(([, b]) => b).join('\n');
328
331
 
329
332
  // Per-skill turn budget. Resolved HERE, once every invoked skill is known (the slash command and
330
333
  // the triggered/sticky set), and GAP-FILLING only: an inline `[turns:N]` or a session-pinned
@@ -347,6 +350,11 @@ export async function* streamChat(opts: {
347
350
  const teamRoster = contacts.formatRoster();
348
351
  const userContextBlock = getUserContextBlock(contact);
349
352
  const contextBlock = [userBlock, userContextBlock, teamRoster, defaultSkills, triggeredSkills, skillIndex, mcpSkills, workspaceTree].filter(Boolean).join('\n');
353
+ const contextSections: Record<string, string> = Object.fromEntries(Object.entries({
354
+ user: userBlock, userContext: userContextBlock, roster: teamRoster, defaultSkills,
355
+ ...Object.fromEntries(triggeredSkillBlocks.map(([n, b]) => [`skill:${n}`, b])),
356
+ skillIndex, mcpSkills, workspace: workspaceTree,
357
+ }).filter(([, v]) => v));
350
358
 
351
359
  // Load conversation for the engine
352
360
  const sessionId = opts.sessionId ?? crypto.randomUUID();
@@ -388,11 +396,16 @@ export async function* streamChat(opts: {
388
396
  return;
389
397
  }
390
398
  console.log(`[stream] engine=${engine.name} user=${opts.uid} session=${sessionId}`);
399
+ // Another engine's turn never reaches the claude-code transcript, so a stored SDK-resume mapping is
400
+ // stale from here on — mark it; the claude-code engine then falls back to a fresh query (engine-switch).
401
+ const resumeState = getSession(sessionId)?.claudeResume;
402
+ if (engine.name !== 'claude-code' && resumeState && !resumeState.interruptedBy) setClaudeResume(sessionId, undefined, { interruptedBy: engine.name });
391
403
 
392
404
  yield* engine.stream({
393
405
  prompt: turnPrompt,
394
406
  conversation,
395
407
  contextBlock,
408
+ contextSections,
396
409
  attachments: opts.attachments,
397
410
  images: opts.images,
398
411
  sessionId,
@@ -4,6 +4,8 @@ export interface Directives {
4
4
  thinking?: 'adaptive' | 'enabled' | 'disabled';
5
5
  effort?: 'low' | 'medium' | 'high' | 'max';
6
6
  engine?: string;
7
+ /** claude-code SDK session resume for this conversation (overrides agent-config `sdkResume`). */
8
+ resume?: boolean;
7
9
  }
8
10
 
9
11
  export interface ParsedPrompt {
@@ -28,7 +30,7 @@ import { MODEL_ALIASES } from './model-aliases.ts';
28
30
 
29
31
  const DIRECTIVE_RE = /^\s*\[([^\]]*)\]\s*([\s\S]*)/;
30
32
 
31
- const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine'];
33
+ const DIRECTIVE_KEYS = ['model', 'turns', 'thinking', 'think', 'effort', 'engine', 'resume'];
32
34
 
33
35
  /** MODEL_ALIASES only covers the bare Anthropic shorthands. A `provider/model` id
34
36
  * (`cursor/composer-2.5`, `openai/gpt-5.6`) is already concrete — gating it on the alias table
@@ -179,6 +181,11 @@ function applyDirective(d: Directives, key: string, val: string): string | undef
179
181
  case 'engine':
180
182
  d.engine = val;
181
183
  break;
184
+ case 'resume':
185
+ if (['on', 'true', '1'].includes(val)) d.resume = true;
186
+ else if (['off', 'false', '0'].includes(val)) d.resume = false;
187
+ else console.warn(`[directives] Invalid resume value: "${val}"`);
188
+ break;
182
189
  default:
183
190
  console.warn(`[directives] Unknown directive key: "${key}"`);
184
191
  }
@@ -8,7 +8,12 @@ import { listSkills } from '../skills.ts';
8
8
  import { loadAgents } from '../agents.ts';
9
9
  import { registerProactiveMessage } from '../slack/sessions.ts';
10
10
  import { registerPoll } from '../polls.ts';
11
- import { getSession, setSessionModel, getSessionModel, type ConvMessage } from '../sessions.ts';
11
+ import { getSession, setSessionModel, getSessionModel, setClaudeResume, type ConvMessage } from '../sessions.ts';
12
+ import {
13
+ isResumeEnabled, decideClaudeTurn, claudeConfigDir, findClaudeTranscript, shortHash, sectionHashes, speakerKey,
14
+ sectionsAfterSubmit, isResumeFailure, conversationSummaryKey, buildContextDelta, buildResumePrompt, renderConvMessage,
15
+ type TurnPath, type ClaudeResumeState,
16
+ } from './claude-resume.ts';
12
17
  import { DEFAULT_MODEL } from '../directives.ts';
13
18
  import { resolveModelSwitch, MODEL_ALIASES } from '../model-aliases.ts';
14
19
  import type { WsEvent, AskQuestion, QuestionAnswers, QuestionHandler } from '../claude.ts';
@@ -95,15 +100,8 @@ function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPromp
95
100
  const parts: string[] = [];
96
101
  if (summary) parts.push(`<conversation_summary>\n${summary}\n</conversation_summary>`);
97
102
  for (const m of recent) {
98
- const role = m.role === 'user' ? 'User' : 'Assistant';
99
- const texts = m.blocks
100
- .filter((b) => b.type === 'text' || b.type === 'context')
101
- .map((b) => {
102
- if (b.type === 'context') return `[${(b as any).label}]: ${(b as any).text}`;
103
- return (b as { type: 'text'; text: string }).text;
104
- })
105
- .filter(Boolean);
106
- if (texts.length) parts.push(`${role}: ${texts.join('\n')}`);
103
+ const line = renderConvMessage(m);
104
+ if (line) parts.push(line);
107
105
  }
108
106
 
109
107
  if (parts.length) {
@@ -117,10 +115,11 @@ function buildHistoryPrompt(conv: ConvMessage[], contextBlock: string, userPromp
117
115
  * Log prompt-cache effectiveness from the SDK result `usage`. The hit rate is
118
116
  * cache_read / (cache_read + cache_creation + uncached input) — a low rate over
119
117
  * many turns points to a silent prefix invalidator or sessions spread past the
120
- * 5-min cache TTL. Note: cross-turn history is re-sent uncached (single-shot
121
- * prompt per query, no SDK resume) — so hit rate tracks tool density per turn.
118
+ * 5-min cache TTL. `path` says how the prompt was built — `fresh` (history re-sent in one message),
119
+ * `resume` (SDK session resumed, only the new message sent) or `fallback:<reason>` (resume enabled but a
120
+ * fresh query ran) — so journal lines can be A/B-compared per path.
122
121
  */
123
- function logCacheUsage(usage: any, model: string): void {
122
+ function logCacheUsage(usage: any, model: string, path: TurnPath): void {
124
123
  if (!usage) return;
125
124
  const read = usage.cache_read_input_tokens ?? 0;
126
125
  const created = usage.cache_creation_input_tokens ?? 0;
@@ -128,9 +127,47 @@ function logCacheUsage(usage: any, model: string): void {
128
127
  const totalIn = read + created + fresh;
129
128
  if (totalIn === 0) return;
130
129
  const hitRate = ((read / totalIn) * 100).toFixed(1);
131
- console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model}`);
130
+ console.log(`[claude] Cache: hit=${hitRate}% read=${read} write=${created} uncached=${fresh} out=${usage.output_tokens ?? 0} model=${model} path=${path}`);
132
131
  }
133
132
 
133
+ /** How one query attempt is built. `persist` is set when resume is enabled: after a query that produced
134
+ * output, the CC session id + these facts are saved so the next turn can resume. */
135
+ interface RunPlan {
136
+ path: TurnPath;
137
+ prompt: string;
138
+ accountDir: string | null;
139
+ resumeId?: string;
140
+ persist?: Omit<ClaudeResumeState, 'claudeSessionId' | 'model' | 'interruptedBy'>;
141
+ /** Resume only: section hashes to store once the prompt is submitted (see sectionsAfterSubmit). */
142
+ submitSections?: Record<string, string>;
143
+ }
144
+
145
+ /** Exit promises of CLI processes still running, per shraga session. A turn that took over a session (external
146
+ * steer) can start while the aborted run's CLI is still exiting; resuming then would put two writers on one
147
+ * transcript. */
148
+ const liveCli = new Map<string, Set<Promise<void>>>();
149
+
150
+ function trackCliExit(sessionId: string | undefined, child: { once: (event: string, fn: () => void) => unknown }): void {
151
+ if (!sessionId) return;
152
+ const set = liveCli.get(sessionId) ?? new Set();
153
+ liveCli.set(sessionId, set);
154
+ const exited = new Promise<void>((resolve) => { child.once('exit', resolve); child.once('error', resolve); });
155
+ set.add(exited);
156
+ void exited.then(() => { set.delete(exited); if (!set.size && liveCli.get(sessionId) === set) liveCli.delete(sessionId); });
157
+ }
158
+
159
+ /** true once every CLI process of the session has exited, false if one is still alive after `ms`. */
160
+ async function cliExited(sessionId: string, ms: number): Promise<boolean> {
161
+ const set = liveCli.get(sessionId);
162
+ if (!set?.size) return true;
163
+ let timer: ReturnType<typeof setTimeout> | undefined;
164
+ const timeout = new Promise<false>((r) => { timer = setTimeout(() => r(false), ms); });
165
+ try { return await Promise.race([Promise.all(set).then(() => true), timeout]); } finally { clearTimeout(timer); }
166
+ }
167
+
168
+ /** Events that prove a resumed query actually runs; until one arrives the attempt can still be retried fresh unseen. */
169
+ const LIVE_EVENTS = new Set<WsEvent['type']>(['text_delta', 'thinking_delta', 'tool_use', 'tool_use_input', 'tool_result', 'tool_result_image', 'done']);
170
+
134
171
  /** SDK spawn hook: same call the SDK would make, plus `detached` (own process group). See the
135
172
  * call site for why. Shape mirrors the SDK's own spawnLocalProcess return. */
136
173
  function spawnDetached(cfg: { command: string; args: string[]; cwd?: string; env: Record<string, string | undefined>; signal?: AbortSignal }) {
@@ -221,11 +258,75 @@ export class ClaudeCodeEngine implements AgentEngine {
221
258
  ];
222
259
  }
223
260
 
261
+ /** How long a resume-enabled turn waits for an earlier CLI process on the session to exit before going fresh. */
262
+ static cliExitWaitMs = 5_000;
263
+
224
264
  async *stream(opts: EngineStreamOpts): AsyncGenerator<WsEvent> {
265
+ let cliAlive = false;
266
+ if (opts.sessionId && isResumeEnabled(opts.directives, opts.config) && !(await cliExited(opts.sessionId, ClaudeCodeEngine.cliExitWaitMs))) {
267
+ cliAlive = true;
268
+ console.warn(`[claude] An earlier CLI process on session=${opts.sessionId} is still running after ${ClaudeCodeEngine.cliExitWaitMs}ms — not resuming its transcript`);
269
+ }
270
+ const plan = this.plan(opts, cliAlive);
271
+ if (plan.path !== 'resume') { yield* this.run(opts, plan); return; }
272
+ // Hold non-output events (init's model_resolved / switch notice) until the resumed query proves it
273
+ // runs, so a failed resume can be retried fresh with nothing duplicated on the user's side.
274
+ const attempt = this.run(opts, plan);
275
+ const held: WsEvent[] = [];
276
+ let live = false;
277
+ let r: IteratorResult<WsEvent, 'resume-failed' | void>;
278
+ try {
279
+ while (!(r = await attempt.next()).done) {
280
+ if (!live && !LIVE_EVENTS.has(r.value.type)) { held.push(r.value); continue; }
281
+ if (!live) { live = true; yield* held; }
282
+ yield r.value;
283
+ }
284
+ } finally {
285
+ await attempt.return(undefined);
286
+ }
287
+ if (r.value !== 'resume-failed') { if (!live) yield* held; return; }
288
+ console.warn(`[claude] Resume of ${plan.resumeId} failed before any output — retrying fresh in the same turn (session=${opts.sessionId})`);
289
+ setClaudeResume(opts.sessionId, undefined);
290
+ yield* this.run(opts, this.freshPlan(opts, plan.accountDir, 'fallback:resume-failed', plan.persist));
291
+ }
292
+
293
+ private freshPlan(opts: EngineStreamOpts, accountDir: string | null, path: TurnPath, persist?: RunPlan['persist']): RunPlan {
294
+ return {
295
+ path, accountDir,
296
+ prompt: buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt),
297
+ ...(persist ? { persist: { ...persist, sections: sectionHashes(opts.contextSections ?? { context: opts.contextBlock }), startedAt: Date.now() } } : {}),
298
+ };
299
+ }
300
+
301
+ /** Resume vs fresh, and why — see claude-resume.ts. */
302
+ private plan(opts: EngineStreamOpts, cliAlive: boolean): RunPlan {
303
+ const accountDir = claudeAccountDir(opts.userEmail);
304
+ if (!isResumeEnabled(opts.directives, opts.config)) return this.freshPlan(opts, accountDir, 'fresh');
305
+ const configDir = claudeConfigDir(accountDir);
306
+ const configDirHash = shortHash(configDir);
307
+ const state = opts.sessionId ? getSession(opts.sessionId)?.claudeResume : undefined;
308
+ const speaker = speakerKey(opts.uid, opts.userEmail);
309
+ const persist = { configDirHash, speaker, markId: opts.conversation.at(-1)?.id, summaryKey: conversationSummaryKey(opts.conversation), sections: {}, startedAt: Date.now() };
310
+ const decision = decideClaudeTurn({
311
+ enabled: true, state, conversation: opts.conversation, configDirHash, speaker, cliAlive, conversationReset: opts.conversationReset,
312
+ hasTranscript: (id) => !!findClaudeTranscript(id, configDir),
313
+ });
314
+ if (decision.path !== 'resume' || !state) return this.freshPlan(opts, accountDir, decision.path, persist);
315
+ const sections = opts.contextSections ?? { context: opts.contextBlock };
316
+ const hashes = sectionHashes(sections);
317
+ return {
318
+ path: 'resume', accountDir, resumeId: state.claudeSessionId,
319
+ prompt: buildResumePrompt(opts.prompt, decision.unseen, buildContextDelta(state.sections, sections)),
320
+ persist: { ...persist, sections: hashes, startedAt: state.startedAt },
321
+ submitSections: sectionsAfterSubmit(state.sections, hashes),
322
+ };
323
+ }
324
+
325
+ private async *run(opts: EngineStreamOpts, plan: RunPlan): AsyncGenerator<WsEvent, 'resume-failed' | void> {
225
326
  const { config, directives } = opts;
226
327
  const cwd = APP_ROOT;
227
328
 
228
- const fullPrompt = buildHistoryPrompt(opts.conversation, opts.contextBlock, opts.prompt);
329
+ const fullPrompt = plan.prompt;
229
330
  const permMode = opts.onPermissionRequest ? 'default' : (config.permissionMode ?? 'acceptEdits');
230
331
 
231
332
  const sdkEnv: Record<string, string> = {};
@@ -251,7 +352,7 @@ export class ClaudeCodeEngine implements AgentEngine {
251
352
  // regardless of settingSources; headless they are auth stubs or self-recursion. Our MCPs come from mcp-config.
252
353
  setIfBlank('ENABLE_CLAUDEAI_MCP_SERVERS', 'false');
253
354
  // Per-user subscription (workspace/users/<contactId>/.claude): run on that login, never the box's credentials.
254
- const accountDir = claudeAccountDir(opts.userEmail);
355
+ const accountDir = plan.accountDir;
255
356
  if (accountDir) applyClaudeAccount(sdkEnv, accountDir);
256
357
  // Which login this run is on (email/plan, never a token) — read lazily from the login's local
257
358
  // files, only once init proves the run is on a subscription login (see init below).
@@ -259,6 +360,10 @@ export class ClaudeCodeEngine implements AgentEngine {
259
360
  const accountRef = () => (accountRefP ??= claudeAccountRef(accountDir));
260
361
  let runAccount: ClaudeAccountRef | undefined;
261
362
  let sawInit = false;
363
+ // Did THIS attempt produce model output? Gates both the resume-failure retry and saving the session id
364
+ // (a resume that died on "No conversation found" can report a session id it never wrote a turn to).
365
+ let sawOutput = false;
366
+ let initModel: string | undefined;
262
367
  sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
263
368
 
264
369
  const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
@@ -357,13 +462,18 @@ export class ClaudeCodeEngine implements AgentEngine {
357
462
  const addonSuffix = getPromptSuffix(opts.turnHints);
358
463
  options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
359
464
  if (opts.abortController) options['abortController'] = opts.abortController;
465
+ if (plan.resumeId) options['resume'] = plan.resumeId;
360
466
  // Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
361
467
  // (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
362
468
  // dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
363
469
  // server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
364
470
  // already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
365
471
  // Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
366
- options['spawnClaudeCodeProcess'] = spawnDetached;
472
+ options['spawnClaudeCodeProcess'] = (cfg: Parameters<typeof spawnDetached>[0]) => {
473
+ const child = spawnDetached(cfg);
474
+ trackCliExit(opts.sessionId, child);
475
+ return child;
476
+ };
367
477
 
368
478
  // Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
369
479
  // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
@@ -391,6 +501,8 @@ export class ClaudeCodeEngine implements AgentEngine {
391
501
  : fullPrompt;
392
502
 
393
503
  const q = query({ prompt, options: options as any });
504
+ // The CLI writes this prompt to the transcript before any output: from here a cancel can't un-deliver it.
505
+ if (plan.submitSections && opts.sessionId) setClaudeResume(opts.sessionId, undefined, { sections: plan.submitSections });
394
506
 
395
507
  let lastSessionId = '';
396
508
  let messageCount = 0;
@@ -449,6 +561,7 @@ export class ClaudeCodeEngine implements AgentEngine {
449
561
  // default only counts when the SDK says it resolved a login, not an API key.
450
562
  if ((authSource ?? (accountDir ? 'subscription' : undefined)) === 'subscription') runAccount = await accountRef();
451
563
  if (m.model) {
564
+ initModel = m.model;
452
565
  console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
453
566
  // If the user explicitly asked to switch models via a [directive], announce the change
454
567
  // inline so the response confirms the switch took effect.
@@ -494,7 +607,7 @@ export class ClaudeCodeEngine implements AgentEngine {
494
607
  const raw = m.subtype ?? 'unknown';
495
608
  const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
496
609
  console.log(`[claude] Result: subtype=${m.subtype}→${sub} session=${lastSessionId} turns=${sdkTurns}/${maxTurns} cost=$${m.total_cost_usd?.toFixed(4) ?? '?'} msgs=${messageCount} deltas=${textDeltaCount} (${elapsed()})`);
497
- logCacheUsage(m.usage, activeModel);
610
+ logCacheUsage(m.usage, activeModel, plan.path);
498
611
  if (outstandingTasks.size > 0) {
499
612
  if (!waitingForBg) {
500
613
  waitingForBg = true; clearBgTimer();
@@ -514,6 +627,12 @@ export class ClaudeCodeEngine implements AgentEngine {
514
627
  // `error_max_turns` also sets is_error but is a benign stop reason we already model.
515
628
  if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
516
629
  const detail = typeof m.result === 'string' && m.result ? m.result : `api_error_status=${m.api_error_status ?? '?'}`;
630
+ // The CLI's reason ("No conversation found with session ID …") rides in `errors`, not `result`.
631
+ const reasons = Array.isArray(m.errors) && m.errors.length ? m.errors.join('; ') : detail;
632
+ if (plan.resumeId && !sawOutput && isResumeFailure(reasons)) {
633
+ console.error(`[claude] Resume error result (subtype=${raw}) — ${reasons}`);
634
+ return 'resume-failed';
635
+ }
517
636
  // A usage/rate-limit failure is about ONE login's quota — name it, or a routed user's own
518
637
  // exhausted Pro plan reads as the shared agent subscription being out.
519
638
  const limitHit = m.api_error_status === 429 || /\b(usage|rate.?limit(ed)?|hit your limit)\b/i.test(detail);
@@ -532,10 +651,12 @@ export class ClaudeCodeEngine implements AgentEngine {
532
651
  if (m.type === 'stream_event') {
533
652
  const event = m.event;
534
653
  if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
654
+ sawOutput = true;
535
655
  yield { type: 'thinking_delta', text: event.delta.thinking };
536
656
  }
537
657
  if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
538
658
  textDeltaCount++;
659
+ sawOutput = true;
539
660
  yield { type: 'text_delta', text: event.delta.text };
540
661
  }
541
662
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
@@ -557,6 +678,7 @@ export class ClaudeCodeEngine implements AgentEngine {
557
678
 
558
679
  if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
559
680
  turnCount++;
681
+ sawOutput = true;
560
682
  for (const block of m.message.content) {
561
683
  if (block.type === 'tool_use') {
562
684
  pendingToolUses.set(block.id, { tool: block.name, input: block.input });
@@ -645,6 +767,7 @@ export class ClaudeCodeEngine implements AgentEngine {
645
767
  return;
646
768
  }
647
769
  console.error(`[claude] Error after ${messageCount} msgs (${elapsed()}):`, err.message || err);
770
+ if (plan.resumeId && !sawOutput && isResumeFailure(err.message || String(err))) return 'resume-failed';
648
771
  yield { type: 'error', message: err.message || String(err) };
649
772
  return;
650
773
  } finally {
@@ -652,6 +775,11 @@ export class ClaudeCodeEngine implements AgentEngine {
652
775
  // The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
653
776
  // widens the window in which the credentials sit on disk.
654
777
  mcpConfigFile?.cleanup();
778
+ // Save the mapping once this attempt really ran a turn (incl. an aborted one — the transcript holds it).
779
+ if (plan.persist && opts.sessionId && lastSessionId && sawOutput) {
780
+ setClaudeResume(opts.sessionId, { ...plan.persist, claudeSessionId: lastSessionId, ...(initModel ? { model: initModel } : {}) });
781
+ if (plan.resumeId && plan.resumeId !== lastSessionId) console.warn(`[claude] Resume returned a new session id ${lastSessionId} (was ${plan.resumeId})`);
782
+ }
655
783
  }
656
784
 
657
785
  const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Opt-in SDK session resume for the claude-code engine — the pure half (decision + prompt building),
3
+ * kept free of SDK/IO wiring so every fallback reason is unit-testable.
4
+ *
5
+ * Fresh turn (today's path): ONE user message = contextBlock + <conversation_history> + new message, so
6
+ * the whole history is re-written to prompt cache every turn. Resume turn: `resume: <claudeSessionId>`
7
+ * and send only the new message; the stable context went in once on the session's fresh query, and
8
+ * whatever CHANGED since (context sections, messages other channels appended) plus the current speaker's
9
+ * `user` section rides AFTER the user text so the cached transcript prefix stays byte-identical. Only one
10
+ * speaker's turns ever share a CC session (speaker-change).
11
+ *
12
+ * Flag: `directives.resume` (per conversation: `[resume:on]` or PUT /api/sessions/:id/directives) over
13
+ * `agent-config.json` `sdkResume` (global, re-read every turn). Default OFF.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, readdirSync } from 'node:fs';
17
+ import { homedir } from 'node:os';
18
+ import path from 'node:path';
19
+ import type { ConvMessage } from '../sessions.ts';
20
+ import type { Directives } from '../directives.ts';
21
+ import type { AgentSettings } from '../shraga-config.ts';
22
+
23
+ /** Persisted on SessionMeta.claudeResume — the facts needed to decide whether the stored CC session
24
+ * may be resumed. Hashes, not content: this lives in the 9 MB sessions index. */
25
+ export interface ClaudeResumeState {
26
+ /** Claude Code session id (SDK init/result `session_id`); resume keeps it stable across turns. */
27
+ claudeSessionId: string;
28
+ /** Hash of the CLAUDE_CONFIG_DIR the transcript was written under (per-user login or box default). */
29
+ configDirHash: string;
30
+ /** speakerKey() of the person whose turns this CC session holds. Another speaker never resumes it: the
31
+ * transcript carries this speaker's private user context (learnings/corrections). */
32
+ speaker?: string;
33
+ /** Model the last turn resolved (informational — CC resumes across models; only the cache is per-model). */
34
+ model?: string;
35
+ /** When this CC session was started (first fresh query). */
36
+ startedAt: number;
37
+ /** Id of the last conversation message at the start of the last turn; messages after it are what CC hasn't seen. */
38
+ markId?: string;
39
+ /** Identity of the newest shraga summary / compact marker when the state was saved. */
40
+ summaryKey: string;
41
+ /** Section name → hash of the context CC has already been given. */
42
+ sections: Record<string, string>;
43
+ /** Set by the core when another engine ran a turn on this session (its turns aren't in the CC transcript). */
44
+ interruptedBy?: string;
45
+ }
46
+
47
+ export type TurnPath = 'resume' | 'fresh' | `fallback:${string}`;
48
+
49
+ /** Unseen out-of-band text above this size means resume would re-send a history anyway — go fresh. */
50
+ export const MAX_UNSEEN_CHARS = 30_000;
51
+
52
+ export function isResumeEnabled(directives: Directives, config: AgentSettings): boolean {
53
+ // The per-session directives endpoint stores passthrough values opaquely, so accept string forms too.
54
+ const v = (directives.resume ?? config.sdkResume) as unknown;
55
+ return v === true || v === 'on' || v === 'true';
56
+ }
57
+
58
+ export function shortHash(text: string): string {
59
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
60
+ }
61
+
62
+ /** Who is speaking, as a hash (the sessions index must not gain raw emails). */
63
+ export function speakerKey(uid: string, email?: string): string {
64
+ return shortHash(`${uid}\n${email?.trim().toLowerCase() ?? ''}`);
65
+ }
66
+
67
+ /** Errors that mean THIS resume can't run (the stored transcript is gone/unreadable) — worth one fresh retry.
68
+ * Anything else (quota, auth, API, process crash) would fail a fresh query the same way: surface it as-is. */
69
+ export function isResumeFailure(text: string): boolean {
70
+ return /No conversation found|\b(session|transcript)\b.{0,60}\b(not found|missing|corrupt|invalid)\b/i.test(text);
71
+ }
72
+
73
+ /** Where the CLI keeps transcripts for a run: the routed per-user login, else the process's config dir. */
74
+ export function claudeConfigDir(accountDir: string | null | undefined): string {
75
+ return accountDir || process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(homedir(), '.claude');
76
+ }
77
+
78
+ /** `<configDir>/projects/<cwd-slug>/<id>.jsonl`, found by scan rather than by re-deriving the CLI's slug
79
+ * rule (a wrong guess would silently turn every resume into a fallback). */
80
+ export function findClaudeTranscript(sessionId: string, configDir: string): string | null {
81
+ const projects = path.join(configDir, 'projects');
82
+ if (!existsSync(projects)) return null;
83
+ for (const dir of readdirSync(projects, { withFileTypes: true })) {
84
+ if (!dir.isDirectory()) continue;
85
+ const file = path.join(projects, dir.name, `${sessionId}.jsonl`);
86
+ if (existsSync(file)) return file;
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** One conversation message as history text — shared by the fresh history prompt and the resume tail. */
92
+ export function renderConvMessage(m: ConvMessage): string | null {
93
+ const texts = m.blocks
94
+ .filter((b) => b.type === 'text' || b.type === 'context')
95
+ .map((b) => (b.type === 'context' ? `[${b.label}]: ${b.text}` : (b as { text: string }).text))
96
+ .filter(Boolean);
97
+ return texts.length ? `${m.role === 'user' ? 'User' : 'Assistant'}: ${texts.join('\n')}` : null;
98
+ }
99
+
100
+ /** Changes whenever maybeCompact writes a summary or /compact adds a marker (applyCompactMarkers
101
+ * turns the latter into a synthetic leading message). */
102
+ export function conversationSummaryKey(conv: ConvMessage[]): string {
103
+ for (let i = conv.length - 1; i >= 0; i--) {
104
+ const s = conv[i].blocks.find((b) => b.type === 'summary') as { compactedCount: number } | undefined;
105
+ if (s) return `summary:${s.compactedCount}`;
106
+ }
107
+ const first = conv[0];
108
+ if (first?.id === 'compact-summary') return `marker:${shortHash(renderConvMessage(first) ?? '')}`;
109
+ return '';
110
+ }
111
+
112
+ /**
113
+ * Messages CC has not seen: everything after the mark, minus the previous turn's own reply (the first
114
+ * message, when it is the assistant's) and this turn's prompt (the last, when it is a user message —
115
+ * every channel appends it before streaming). null = the mark is gone (history was rewritten).
116
+ */
117
+ export function unseenMessages(conv: ConvMessage[], markId: string | undefined): ConvMessage[] | null {
118
+ if (!markId) return null;
119
+ const idx = conv.findLastIndex((m) => m.id === markId);
120
+ if (idx < 0) return null;
121
+ const after = conv.slice(idx + 1);
122
+ if (after[0]?.role === 'assistant') after.shift();
123
+ if (after.at(-1)?.role === 'user') after.pop();
124
+ return after;
125
+ }
126
+
127
+ export interface TurnDecisionInput {
128
+ enabled: boolean;
129
+ state?: ClaudeResumeState;
130
+ conversation: ConvMessage[];
131
+ configDirHash: string;
132
+ speaker: string;
133
+ /** A CLI process from an earlier run on this session is still alive (e.g. the run a steer took over). */
134
+ cliAlive?: boolean;
135
+ conversationReset?: boolean;
136
+ hasTranscript: (claudeSessionId: string) => boolean;
137
+ }
138
+
139
+ export function decideClaudeTurn(i: TurnDecisionInput): { path: TurnPath; unseen: ConvMessage[] } {
140
+ const fallback = (reason: string) => ({ path: `fallback:${reason}` as TurnPath, unseen: [] });
141
+ if (!i.enabled) return { path: 'fresh', unseen: [] };
142
+ const s = i.state;
143
+ if (!s?.claudeSessionId) return fallback('no-session');
144
+ // Two CLI processes appending to one transcript can interleave it; the takeover turn starts its own.
145
+ if (i.cliAlive) return fallback('concurrent-run');
146
+ if (s.speaker !== i.speaker) return fallback('speaker-change');
147
+ if (s.interruptedBy) return fallback('engine-switch');
148
+ if (s.configDirHash !== i.configDirHash) return fallback('account-change');
149
+ if (i.conversationReset) return fallback('reset');
150
+ if (s.summaryKey !== conversationSummaryKey(i.conversation)) return fallback('summary');
151
+ const unseen = unseenMessages(i.conversation, s.markId);
152
+ if (!unseen) return fallback('history-diverged');
153
+ if (unseen.reduce((n, m) => n + (renderConvMessage(m)?.length ?? 0), 0) > MAX_UNSEEN_CHARS) return fallback('drift');
154
+ // Checked last (filesystem): the CLI's periodic cleanup (cleanupPeriodDays) or a moved config dir
155
+ // deletes transcripts under sessions we still hold — catch it before spawning a doomed resume.
156
+ if (!i.hasTranscript(s.claudeSessionId)) return fallback('transcript-missing');
157
+ return { path: 'resume', unseen };
158
+ }
159
+
160
+ export function sectionHashes(sections: Record<string, string>): Record<string, string> {
161
+ const out: Record<string, string> = {};
162
+ for (const [k, v] of Object.entries(sections)) if (v) out[k] = shortHash(v);
163
+ return out;
164
+ }
165
+
166
+ /** Re-sent on every resume turn regardless of hashes: who is speaking (identity + role) must never depend on
167
+ * bookkeeping about what an earlier, possibly cancelled, turn managed to deliver. Small. */
168
+ export const ALWAYS_SENT_SECTIONS = ['user'];
169
+
170
+ /** Hash marking a section whose delivery is unknown — never equals a real hash, so the next turn re-sends it. */
171
+ const UNKNOWN = '?';
172
+
173
+ /**
174
+ * Section hashes to store the moment a resume prompt is SUBMITTED. The CLI writes the prompt to the
175
+ * transcript before any output, so a turn cancelled early may or may not have delivered its delta. Unchanged
176
+ * sections are known either way; changed, new and removed ones are marked unknown and re-sent next turn.
177
+ */
178
+ export function sectionsAfterSubmit(prev: Record<string, string>, next: Record<string, string>): Record<string, string> {
179
+ const out: Record<string, string> = {};
180
+ for (const k of new Set([...Object.keys(prev), ...Object.keys(next)])) out[k] = prev[k] === next[k] ? prev[k] : UNKNOWN;
181
+ return out;
182
+ }
183
+
184
+ /** The context sections that changed since CC last saw them (new or edited) plus ALWAYS_SENT_SECTIONS, and a
185
+ * note for sections that no longer apply. '' when there is nothing to send. */
186
+ export function buildContextDelta(prev: Record<string, string>, sections: Record<string, string>): string {
187
+ const changed = Object.entries(sections).filter(([k, v]) => v && (ALWAYS_SENT_SECTIONS.includes(k) || prev[k] !== shortHash(v)));
188
+ const removed = Object.keys(prev).filter((k) => !sections[k]);
189
+ if (!changed.length && !removed.length) return '';
190
+ return [
191
+ '<context_update>',
192
+ 'Current context for this turn. Each section below replaces its earlier version.',
193
+ ...changed.map(([k, v]) => `<section name="${k}">\n${v}\n</section>`),
194
+ ...(removed.length ? [`No longer applicable: ${removed.join(', ')}`] : []),
195
+ '</context_update>',
196
+ ].join('\n');
197
+ }
198
+
199
+ export function buildResumePrompt(prompt: string, unseen: ConvMessage[], delta: string): string {
200
+ const lines = unseen.map(renderConvMessage).filter(Boolean);
201
+ const unseenBlock = lines.length
202
+ ? `<messages_since_last_turn>\nAdded to this conversation since your last reply (other participants, channel context, notices):\n${lines.join('\n\n')}\n</messages_since_last_turn>`
203
+ : '';
204
+ return [prompt, unseenBlock, delta].filter(Boolean).join('\n\n');
205
+ }
@@ -11,6 +11,9 @@ export interface EngineStreamOpts {
11
11
  conversation: ConvMessage[];
12
12
  /** Contextual blocks to prepend (user block, skills, workspace tree, etc.) */
13
13
  contextBlock: string;
14
+ /** The same context split into named sections (in contextBlock order), so an engine that keeps
15
+ * state across turns can send only what changed. Joined with '\n' they equal contextBlock. */
16
+ contextSections?: Record<string, string>;
14
17
 
15
18
  attachments?: AttachmentMeta[];
16
19
  images?: string[];