shraga 0.1.110 → 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-BaZu5Ojo.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.110",
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",
@@ -55,7 +55,7 @@
55
55
  "prepublishOnly": "bun run guard:publish && bun run build"
56
56
  },
57
57
  "dependencies": {
58
- "@anthropic-ai/claude-agent-sdk": "^0.2.141",
58
+ "@anthropic-ai/claude-agent-sdk": "^0.3.272",
59
59
  "@livx.cc/mcp-firebase": "^0.1.14",
60
60
  "@radix-ui/react-accordion": "^1.2.2",
61
61
  "@radix-ui/react-dialog": "^1.1.4",
@@ -4,6 +4,12 @@
4
4
  // MCP_EXTRA_HEADERS — optional JSON object of extra headers sent with every request,
5
5
  // for servers that need a second credential beyond the bearer token (e.g. an
6
6
  // upstream app behind a proxy that gates its own admin ops on its own header).
7
+ // MCP_BRIDGE_TIMEOUT_MS — per-request bound for protocol calls (initialize, tools/list…), default 60s.
8
+ // MCP_BRIDGE_TOOL_TIMEOUT_MS — bound for tools/call, default 30min (a sync post_chat turn has no
9
+ // server-side wall clock). Without a bound, a frozen server hung the client forever on "connecting…".
10
+ const envMs = (v: string | undefined, fallback: number) => (Number(v) > 0 ? Number(v) : fallback);
11
+ const REQUEST_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TIMEOUT_MS, 60_000);
12
+ const TOOL_TIMEOUT_MS = envMs(process.env.MCP_BRIDGE_TOOL_TIMEOUT_MS, 30 * 60_000);
7
13
  const baseUrl = (process.env.SHRAGA_URL || process.env.MCP_URL || process.env.UNCLAW_URL || 'http://localhost:3033').replace(/\/$/, '');
8
14
  const mcpPath = process.env.SHRAGA_MCP_PATH || process.env.MCP_PATH || '/mcp';
9
15
  const apiKey = process.env.SHRAGA_API_KEY || process.env.MCP_API_KEY || process.env.UNCLAW_API_KEY;
@@ -33,11 +39,23 @@ async function sendMessage(message: any): Promise<void> {
33
39
  if (sessionId) headers['mcp-session-id'] = sessionId;
34
40
  Object.assign(headers, extraHeaders);
35
41
 
36
- const res = await fetch(`${baseUrl}${mcpPath}`, {
37
- method: 'POST',
38
- headers,
39
- body: JSON.stringify(message),
40
- });
42
+ const limit = message?.method === 'tools/call' ? TOOL_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
43
+ // The signal also bounds the body read below, not just the headers.
44
+ const signal = AbortSignal.timeout(limit);
45
+ let res: Response;
46
+ let text: string;
47
+ try {
48
+ res = await fetch(`${baseUrl}${mcpPath}`, {
49
+ method: 'POST',
50
+ headers,
51
+ body: JSON.stringify(message),
52
+ signal,
53
+ });
54
+ text = res.status === 204 ? '' : await res.text();
55
+ } catch (e: any) {
56
+ if (signal.aborted) throw new Error(`no response from ${baseUrl} for ${message?.method ?? 'message'} within ${limit}ms`);
57
+ throw e;
58
+ }
41
59
 
42
60
  // Capture session ID from server on initialize
43
61
  const newSession = res.headers.get('mcp-session-id');
@@ -49,7 +67,6 @@ async function sendMessage(message: any): Promise<void> {
49
67
 
50
68
  if (contentType.includes('text/event-stream')) {
51
69
  // SSE: parse and forward each data line
52
- const text = await res.text();
53
70
  for (const line of text.split('\n')) {
54
71
  if (line.startsWith('data: ')) {
55
72
  const data = line.slice(6).trim();
@@ -58,9 +75,8 @@ async function sendMessage(message: any): Promise<void> {
58
75
  }
59
76
  }
60
77
  }
61
- } else {
62
- const body = await res.text();
63
- if (body) process.stdout.write(body + '\n');
78
+ } else if (text) {
79
+ process.stdout.write(text + '\n');
64
80
  }
65
81
  }
66
82
 
@@ -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,
@@ -213,30 +213,27 @@ export class DataSync {
213
213
  } catch (err) {
214
214
  console.error(`${TAG} Boot sync FAILED — serving stale data until the next pull:`, (err as Error).message);
215
215
  }
216
- // Defer heavy sync I/O (reads all tracked files + execSync) to avoid blocking
217
- // WS connections and page loads during startup.
216
+ // Defer the post-boot scans (both async, but they still read/spawn a lot) past startup
217
+ // traffic — WS connections and page loads.
218
218
  setTimeout(() => {
219
219
  this.scanForConflictMarkers()
220
220
  .catch(err => console.warn(`${TAG} Post-init conflict scan failed:`, (err as Error).message))
221
- // execSync inside the audit blocks too — keep it off the scan's tick so the two
222
- // never add up into one long freeze.
223
- .then(() => new Promise<void>(r => setImmediate(r)))
224
221
  .then(() => this.runIntegrityAudit())
225
222
  .catch(err => console.warn(`${TAG} Post-init integrity audit failed:`, (err as Error).message));
226
223
  }, 60_000);
227
224
  }
228
225
 
229
- /** Compare HEAD against HEAD~1 to catch regressions. Notifies owner — never auto-reverts. */
230
- private runIntegrityAudit(): void {
226
+ /** Compare HEAD against HEAD~1 to catch regressions. Notifies owner — never auto-reverts.
227
+ * Must stay async: the old execSync-per-file audit froze the event loop ~5 min on prod. */
228
+ private async runIntegrityAudit(): Promise<void> {
231
229
  try {
232
- const { audit } = require('./integrity-audit.ts');
230
+ const { audit } = await import('./integrity-audit.ts');
233
231
  // Churn paths are exempt for the same reason the shrink guard exempts them: a per-run worker
234
232
  // log is written by one leg, rewritten by the next, and truncated whenever a run is killed
235
233
  // mid-write. "invalid-json" on one of those is the normal end of an interrupted run, not
236
234
  // corruption of shared data — and since the file stays in HEAD, the audit re-reported it on
237
235
  // every sync. The audit exists to catch contacts.json being gutted.
238
- const issues = (audit('HEAD~1') as { kind: string; file: string; detail: string }[])
239
- .filter(i => !isChurnPath(i.file));
236
+ const issues = (await audit('HEAD~1', DATA_DIR)).filter(i => !isChurnPath(i.file));
240
237
  if (issues.length) {
241
238
  console.warn(`${TAG} ⚠️ DATA INTEGRITY: ${issues.length} issue(s) detected after sync:`);
242
239
  for (const { kind, file, detail } of issues) {
@@ -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> = {};
@@ -247,8 +348,11 @@ export class ClaudeCodeEngine implements AgentEngine {
247
348
  const setIfBlank = (key: string, value: string) => { if (!sdkEnv[key]?.trim()) sdkEnv[key] = value; };
248
349
  setIfBlank('BASH_DEFAULT_TIMEOUT_MS', process.env.AGENT_SHELL_TIMEOUT_MS?.trim() || '60000');
249
350
  setIfBlank('BASH_MAX_TIMEOUT_MS', process.env.AGENT_SHELL_MAX_TIMEOUT_MS?.trim() || '600000');
351
+ // The subscription login's claude.ai connectors (Gmail/Calendar/… and this agent's own MCP) load
352
+ // regardless of settingSources; headless they are auth stubs or self-recursion. Our MCPs come from mcp-config.
353
+ setIfBlank('ENABLE_CLAUDEAI_MCP_SERVERS', 'false');
250
354
  // Per-user subscription (workspace/users/<contactId>/.claude): run on that login, never the box's credentials.
251
- const accountDir = claudeAccountDir(opts.userEmail);
355
+ const accountDir = plan.accountDir;
252
356
  if (accountDir) applyClaudeAccount(sdkEnv, accountDir);
253
357
  // Which login this run is on (email/plan, never a token) — read lazily from the login's local
254
358
  // files, only once init proves the run is on a subscription login (see init below).
@@ -256,6 +360,10 @@ export class ClaudeCodeEngine implements AgentEngine {
256
360
  const accountRef = () => (accountRefP ??= claudeAccountRef(accountDir));
257
361
  let runAccount: ClaudeAccountRef | undefined;
258
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;
259
367
  sdkEnv.INTERNAL_API_TOKEN = signInternalToken(opts.uid, opts.userEmail || 'unknown');
260
368
 
261
369
  const baseAllowed = config.allowedTools ?? DEFAULT_ALLOWED_TOOLS;
@@ -267,6 +375,10 @@ export class ClaudeCodeEngine implements AgentEngine {
267
375
  env: sdkEnv,
268
376
  allowedTools,
269
377
  cwd,
378
+ // Omitted = ALL sources, so the host's ~/.claude (CLAUDE.md, MCP instructions, hooks) rode into every
379
+ // query — ~24k tokens re-written to cache per fresh turn on a dev box. Keep 'project': the deployment
380
+ // ships its own .claude/skills (e.g. shraga-circles' video-ad-pipeline) that the agent relies on.
381
+ settingSources: ['project'],
270
382
  permissionMode: permMode === 'bypassPermissions' ? 'acceptEdits' : permMode,
271
383
  maxTurns,
272
384
  includePartialMessages: true,
@@ -350,13 +462,18 @@ export class ClaudeCodeEngine implements AgentEngine {
350
462
  const addonSuffix = getPromptSuffix(opts.turnHints);
351
463
  options['systemPrompt'] = `${IMMUTABLE_SYSTEM_PROMPT}\n\n${userPrompt}${addonSuffix ? `\n\n${addonSuffix}` : ''}`;
352
464
  if (opts.abortController) options['abortController'] = opts.abortController;
465
+ if (plan.resumeId) options['resume'] = plan.resumeId;
353
466
  // Spawn the CLI in its OWN process group. The service manager signals the whole JOB on restart
354
467
  // (`launchctl kickstart -k`, `systemctl restart`), so an inherited process group means the child
355
468
  // dies instantly with SIGTERM — surfacing mid-reply as `exited with code 143` and making the
356
469
  // server's 90s drain (gracefulShutdown) protect nothing: it only ever waited for a turn that was
357
470
  // already dead. Detached, the signal reaches the server alone and the drain can finish the turn.
358
471
  // Teardown is unaffected: the SDK still kills the child on abort/close and on process exit.
359
- 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
+ };
360
477
 
361
478
  // Passed as a FILE, never as `options.mcpServers` — the SDK would put the whole config (every MCP
362
479
  // server's credentials) on the CLI's argv, where `ps` / `/proc` / journald expose it. See
@@ -384,6 +501,8 @@ export class ClaudeCodeEngine implements AgentEngine {
384
501
  : fullPrompt;
385
502
 
386
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 });
387
506
 
388
507
  let lastSessionId = '';
389
508
  let messageCount = 0;
@@ -442,6 +561,7 @@ export class ClaudeCodeEngine implements AgentEngine {
442
561
  // default only counts when the SDK says it resolved a login, not an API key.
443
562
  if ((authSource ?? (accountDir ? 'subscription' : undefined)) === 'subscription') runAccount = await accountRef();
444
563
  if (m.model) {
564
+ initModel = m.model;
445
565
  console.log(`[claude] Init model=${m.model}${m.model !== options['model'] ? ` (requested ${options['model']})` : ''}`);
446
566
  // If the user explicitly asked to switch models via a [directive], announce the change
447
567
  // inline so the response confirms the switch took effect.
@@ -487,7 +607,7 @@ export class ClaudeCodeEngine implements AgentEngine {
487
607
  const raw = m.subtype ?? 'unknown';
488
608
  const sub = raw === 'error_max_turns' || (raw === 'end_turn' && sdkTurns >= maxTurns) ? 'max_turns_reached' : raw;
489
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()})`);
490
- logCacheUsage(m.usage, activeModel);
610
+ logCacheUsage(m.usage, activeModel, plan.path);
491
611
  if (outstandingTasks.size > 0) {
492
612
  if (!waitingForBg) {
493
613
  waitingForBg = true; clearBgTimer();
@@ -507,6 +627,12 @@ export class ClaudeCodeEngine implements AgentEngine {
507
627
  // `error_max_turns` also sets is_error but is a benign stop reason we already model.
508
628
  if ((m.is_error || m.api_error_status) && sub !== 'max_turns_reached') {
509
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
+ }
510
636
  // A usage/rate-limit failure is about ONE login's quota — name it, or a routed user's own
511
637
  // exhausted Pro plan reads as the shared agent subscription being out.
512
638
  const limitHit = m.api_error_status === 429 || /\b(usage|rate.?limit(ed)?|hit your limit)\b/i.test(detail);
@@ -525,10 +651,12 @@ export class ClaudeCodeEngine implements AgentEngine {
525
651
  if (m.type === 'stream_event') {
526
652
  const event = m.event;
527
653
  if (event?.type === 'content_block_delta' && event.delta?.type === 'thinking_delta') {
654
+ sawOutput = true;
528
655
  yield { type: 'thinking_delta', text: event.delta.thinking };
529
656
  }
530
657
  if (event?.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
531
658
  textDeltaCount++;
659
+ sawOutput = true;
532
660
  yield { type: 'text_delta', text: event.delta.text };
533
661
  }
534
662
  if (event?.type === 'content_block_start' && event.content_block?.type === 'tool_use') {
@@ -550,6 +678,7 @@ export class ClaudeCodeEngine implements AgentEngine {
550
678
 
551
679
  if (m.type === 'assistant' && Array.isArray(m.message?.content)) {
552
680
  turnCount++;
681
+ sawOutput = true;
553
682
  for (const block of m.message.content) {
554
683
  if (block.type === 'tool_use') {
555
684
  pendingToolUses.set(block.id, { tool: block.name, input: block.input });
@@ -638,6 +767,7 @@ export class ClaudeCodeEngine implements AgentEngine {
638
767
  return;
639
768
  }
640
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';
641
771
  yield { type: 'error', message: err.message || String(err) };
642
772
  return;
643
773
  } finally {
@@ -645,6 +775,11 @@ export class ClaudeCodeEngine implements AgentEngine {
645
775
  // The CLI has read the file by now (it loads MCP config at startup); holding it any longer just
646
776
  // widens the window in which the credentials sit on disk.
647
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
+ }
648
783
  }
649
784
 
650
785
  const inferredReason = turnCount >= maxTurns ? 'max_turns_reached' : 'end_turn';