@app-vitals/agent-runtime 0.0.0-stage → 0.1.0

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.
Files changed (160) hide show
  1. package/Dockerfile +265 -0
  2. package/agent-account-status-ref.ts +53 -0
  3. package/agent-account-status-ref.unit.test.ts +24 -0
  4. package/agent-extension.ts +144 -0
  5. package/agent-extension.unit.test.ts +139 -0
  6. package/agent-repos-ref.ts +63 -0
  7. package/agent-repos-ref.unit.test.ts +84 -0
  8. package/agent-slack-membership-ref.ts +94 -0
  9. package/agent-slack-membership-ref.unit.test.ts +201 -0
  10. package/agent-trial-expiry-ref.ts +63 -0
  11. package/agent-trial-expiry-ref.unit.test.ts +84 -0
  12. package/anthropic-federation-guard.ts +72 -0
  13. package/anthropic-federation-guard.unit.test.ts +127 -0
  14. package/base-image-contract.smoke.test.ts +149 -0
  15. package/boot.ts +845 -0
  16. package/browser.integration.test.ts +52 -0
  17. package/browser.ts +34 -0
  18. package/chat-poller.integration.test.ts +109 -0
  19. package/chat-poller.ts +401 -0
  20. package/chat-poller.unit.test.ts +1581 -0
  21. package/chat-token-reporter.integration.test.ts +282 -0
  22. package/chat-token-reporter.ts +136 -0
  23. package/claude-version.ts +55 -0
  24. package/claude-version.unit.test.ts +73 -0
  25. package/claude.ts +1054 -0
  26. package/claude.unit.test.ts +3042 -0
  27. package/cleanup-after-days-ref.ts +83 -0
  28. package/cleanup-after-days-ref.unit.test.ts +89 -0
  29. package/cleanup-merged-worktrees-ref.ts +75 -0
  30. package/cleanup-merged-worktrees-ref.unit.test.ts +72 -0
  31. package/cli-args.ts +36 -0
  32. package/cli-args.unit.test.ts +94 -0
  33. package/clock.ts +47 -0
  34. package/config.ts +99 -0
  35. package/config.unit.test.ts +356 -0
  36. package/context-stamp.ts +189 -0
  37. package/context-stamp.unit.test.ts +159 -0
  38. package/cron-failure-reporter.ts +107 -0
  39. package/cron-failure-reporter.unit.test.ts +244 -0
  40. package/cron-handler.integration.test.ts +614 -0
  41. package/cron-handler.smoke.test.ts +1371 -0
  42. package/cron-handler.ts +750 -0
  43. package/cron-handler.unit.test.ts +1177 -0
  44. package/cron-run-reporter.integration.test.ts +806 -0
  45. package/cron-run-reporter.ts +398 -0
  46. package/crypto.ts +73 -0
  47. package/cutover-validate.integration.test.ts +245 -0
  48. package/cutover-validate.ts +84 -0
  49. package/entrypoint-main.ts +79 -0
  50. package/entrypoint.integration.test.ts +315 -0
  51. package/entrypoint.ts +154 -0
  52. package/errors.ts +61 -0
  53. package/fixtures/chat-service/attachment.json +15 -0
  54. package/fixtures/chat-service/messages.json +86 -0
  55. package/fixtures/chat-service/threads.json +50 -0
  56. package/fixtures/github-installations/installations.json +1231 -0
  57. package/fixtures/slack-provision-cassette.json +6 -0
  58. package/fixtures/specialties/fixture/manifest.yaml +35 -0
  59. package/fixtures/specialties/fixture/templates/CLAUDE.md.template +7 -0
  60. package/fixtures/specialties/fixture/templates/IDENTITY.md.template +8 -0
  61. package/fixtures/specialties/fixture/templates/SOUL.md.template +45 -0
  62. package/fixtures/stream-json/clean-exit-no-result.ts +53 -0
  63. package/fixtures/stream-json/clean-multi-turn.ts +209 -0
  64. package/fixtures/stream-json/progress-phases.ts +203 -0
  65. package/fixtures/stream-json/skill-attribution.ts +251 -0
  66. package/fixtures/stream-json/truncated-no-result.ts +84 -0
  67. package/format.ts +283 -0
  68. package/format.unit.test.ts +558 -0
  69. package/gh-helpers.ts +139 -0
  70. package/gh-helpers.unit.test.ts +253 -0
  71. package/gh-token-files.integration.test.ts +143 -0
  72. package/gh-token-files.ts +92 -0
  73. package/gh-token-files.unit.test.ts +63 -0
  74. package/github-app-auth.integration.test.ts +419 -0
  75. package/github-app-auth.ts +193 -0
  76. package/github-auth-deps.ts +128 -0
  77. package/github-auth-startup.ts +231 -0
  78. package/github-auth-startup.unit.test.ts +325 -0
  79. package/github-installations-manager.integration.test.ts +323 -0
  80. package/github-installations-reporter.integration.test.ts +124 -0
  81. package/github-installations-reporter.ts +150 -0
  82. package/github-installations-reporter.unit.test.ts +166 -0
  83. package/github-installations.integration.test.ts +88 -0
  84. package/github-installations.ts +462 -0
  85. package/github-installations.unit.test.ts +98 -0
  86. package/health.smoke.test.ts +244 -0
  87. package/health.ts +193 -0
  88. package/health.unit.test.ts +40 -0
  89. package/http-chat-service-client.integration.test.ts +345 -0
  90. package/http-chat-service-client.ts +287 -0
  91. package/http-chat-service-client.unit.test.ts +500 -0
  92. package/log-prefix.ts +12 -0
  93. package/log-prefix.unit.test.ts +24 -0
  94. package/markers.ts +178 -0
  95. package/markers.unit.test.ts +374 -0
  96. package/package.json +40 -4
  97. package/piper-voice.ts +116 -0
  98. package/piper-voice.unit.test.ts +124 -0
  99. package/pricing.ts +7 -0
  100. package/pricing.unit.test.ts +149 -0
  101. package/process-tree-kill.integration.test.ts +133 -0
  102. package/process-tree-kill.ts +325 -0
  103. package/process-tree-kill.unit.test.ts +265 -0
  104. package/progress-milestones.ts +86 -0
  105. package/progress-milestones.unit.test.ts +188 -0
  106. package/run-telemetry.ts +241 -0
  107. package/run-telemetry.unit.test.ts +235 -0
  108. package/sessions.integration.test.ts +273 -0
  109. package/sessions.ts +191 -0
  110. package/sessions.unit.test.ts +94 -0
  111. package/setup-github-auth.integration.test.ts +858 -0
  112. package/setup-github-auth.ts +297 -0
  113. package/setup.fixture.integration.test.ts +109 -0
  114. package/setup.integration.test.ts +1495 -0
  115. package/setup.ts +853 -0
  116. package/setup.unit.test.ts +286 -0
  117. package/shipwright-config-client.ts +36 -0
  118. package/shipwright-runtime-client.integration.test.ts +336 -0
  119. package/shipwright-runtime-client.ts +118 -0
  120. package/slack-account-status.unit.test.ts +251 -0
  121. package/slack-bolt-client.integration.test.ts +76 -0
  122. package/slack-chat-conversation.integration.test.ts +333 -0
  123. package/slack-progress.ts +510 -0
  124. package/slack-progress.unit.test.ts +806 -0
  125. package/slack-socket-mode-ping.integration.test.ts +137 -0
  126. package/slack-startup.ts +170 -0
  127. package/slack-startup.unit.test.ts +300 -0
  128. package/slack-thread-status-tracker.ts +70 -0
  129. package/slack-thread-status-tracker.unit.test.ts +118 -0
  130. package/slack-trial-expiry.unit.test.ts +269 -0
  131. package/slack.integration.test.ts +3979 -0
  132. package/slack.ts +1286 -0
  133. package/slack.unit.test.ts +1070 -0
  134. package/spawn-env.ts +58 -0
  135. package/spawn-env.unit.test.ts +96 -0
  136. package/startup-dm.integration.test.ts +156 -0
  137. package/startup-dm.ts +29 -0
  138. package/sync-config-clone.ts +238 -0
  139. package/sync-config-clone.unit.test.ts +429 -0
  140. package/test-env.ts +27 -0
  141. package/users.ts +90 -0
  142. package/users.unit.test.ts +108 -0
  143. package/voice.integration.test.ts +669 -0
  144. package/voice.ts +315 -0
  145. package/voice.unit.test.ts +105 -0
  146. package/workspace/BOOTSTRAP.md.template +43 -0
  147. package/workspace/BOOTSTRAP.md.template.content.test.ts +48 -0
  148. package/workspace/IDENTITY.md.template +8 -0
  149. package/workspace/RUNTIME.md.template +193 -0
  150. package/workspace/RUNTIME.md.template.content.test.ts +75 -0
  151. package/workspace/SOUL.md.template +45 -0
  152. package/workspace/SPECIALTY.md.template +37 -0
  153. package/workspace/SPECIALTY.md.template.content.test.ts +50 -0
  154. package/workspace/VOICE.md.template +8 -0
  155. package/workspace/mise.toml.template +12 -0
  156. package/workspace/requirements.txt.template +1 -0
  157. package/workspace-paths.ts +37 -0
  158. package/worktree-reaper.ts +244 -0
  159. package/worktree-reaper.unit.test.ts +380 -0
  160. package/README.md +0 -3
package/claude.ts ADDED
@@ -0,0 +1,1054 @@
1
+ import { DEFAULT_ANTHROPIC_MODEL } from "@app-vitals/agent-lib/default-agent-env";
2
+ import type { ProgressPhase } from "@app-vitals/agent-lib/progress-phases";
3
+ import type { ErrorCapturingClient } from "@app-vitals/agent-lib/sentry";
4
+ import {
5
+ createRunCgroup,
6
+ killTree,
7
+ markCgroupUnavailable,
8
+ removeRunCgroup,
9
+ } from "./process-tree-kill.ts";
10
+ import { type ContentBlock, phaseForBlock } from "./progress-milestones.ts";
11
+ import { type RunTelemetry, RunTelemetryAccumulator } from "./run-telemetry.ts";
12
+
13
+ export type { RunTelemetry } from "./run-telemetry.ts";
14
+
15
+ interface LiveClaudeConfig {
16
+ model: string;
17
+ fallbackModel?: string;
18
+ effortLevel?: string;
19
+ allowedTools: string[];
20
+ }
21
+
22
+ /**
23
+ * Live Claude configuration — reads from process.env directly.
24
+ * Updated at runtime by setLiveClaudeConfig (e.g. from agent config polling).
25
+ */
26
+ export const liveClaudeConfig: LiveClaudeConfig = {
27
+ model: process.env.ANTHROPIC_MODEL ?? DEFAULT_ANTHROPIC_MODEL,
28
+ fallbackModel: process.env.ANTHROPIC_FALLBACK_MODEL,
29
+ effortLevel: process.env.ANTHROPIC_EFFORT_LEVEL,
30
+ allowedTools: process.env.AGENT_ALLOWED_TOOLS
31
+ ? JSON.parse(process.env.AGENT_ALLOWED_TOOLS)
32
+ : [],
33
+ };
34
+
35
+ export function setLiveClaudeConfig(patch: Partial<LiveClaudeConfig>): void {
36
+ Object.assign(liveClaudeConfig, patch);
37
+ }
38
+
39
+ /**
40
+ * The unconditional tool floor granted to every Claude session, regardless of
41
+ * what's seeded in the AgentTool DB table. These are read-only or
42
+ * context-only tools with no meaningful security delta from Read (which is
43
+ * already assumed safe) — they are permanently non-revocable.
44
+ *
45
+ * Bash, WebSearch, WebFetch, and Agent are deliberately NOT included here:
46
+ * they now flow entirely through `liveClaudeConfig.allowedTools` /
47
+ * `extraAllowedTools`, sourced from the AgentTool DB table (seeded at agent
48
+ * creation from the resolved Specialty manifest — see
49
+ * admin/src/agents-api.ts — and
50
+ * backfilled for existing agents). This is the capability-narrowing step: an
51
+ * agent with no AgentTool rows (or a 404'd config bundle) no longer gets
52
+ * Bash/WebSearch/WebFetch/Agent for free.
53
+ */
54
+ export const FLOOR_TOOLS = [
55
+ "Read",
56
+ "Write",
57
+ "Edit",
58
+ "Glob",
59
+ "Grep",
60
+ "TodoWrite",
61
+ "Skill",
62
+ ];
63
+
64
+ export interface TokenUsage {
65
+ input_tokens: number;
66
+ output_tokens: number;
67
+ cache_read_input_tokens: number;
68
+ cache_creation_input_tokens: number;
69
+ }
70
+
71
+ /**
72
+ * Per-model token usage entry, as emitted by the Claude CLI's `modelUsage`
73
+ * map (camelCase) — distinct from the snake_case top-level `usage` shape.
74
+ */
75
+ interface ModelUsageEntry {
76
+ inputTokens: number;
77
+ outputTokens: number;
78
+ cacheReadInputTokens: number;
79
+ cacheCreationInputTokens: number;
80
+ costUSD: number;
81
+ webSearchRequests?: number;
82
+ contextWindow?: number;
83
+ maxOutputTokens?: number;
84
+ }
85
+
86
+ /** Per-model token usage map: model name → ModelUsageEntry. */
87
+ export type ModelUsage = Record<string, ModelUsageEntry>;
88
+
89
+ export interface ClaudeRunResult {
90
+ result: string;
91
+ sessionId?: string;
92
+ usage?: TokenUsage;
93
+ totalCostUsd?: number;
94
+ modelUsage?: ModelUsage;
95
+ recoveredFromError?: boolean;
96
+ /**
97
+ * True when the stream ended cleanly (no process error) but never emitted a
98
+ * terminal `result` event. In that case `result` is empty and `modelUsage`
99
+ * carries whatever per-model usage was accumulated from the `assistant`
100
+ * lines (costUSD unknown → 0), rather than throwing all of it away.
101
+ *
102
+ * Caveat: this accumulated `modelUsage` (and the partial usage attached to
103
+ * `ClaudeTimeoutError`/`ClaudeRunError`) undercounts `outputTokens`. Each
104
+ * `assistant` line's `usage.output_tokens` only reflects *visible* output —
105
+ * extended-thinking tokens are billed as output but often come back with an
106
+ * empty/redacted `thinking` field, so they never show up in that per-line
107
+ * total. Only the terminal `result` event's `modelUsage` is authoritative;
108
+ * treat any partial/incomplete usage as a lower bound, not a true count.
109
+ * Confirmed against real `claude -p --output-format stream-json --verbose`
110
+ * captures during CSU-1.1 validation — see anthropics/claude-code#27361 and
111
+ * #64153.
112
+ */
113
+ streamIncomplete?: boolean;
114
+ /**
115
+ * Per-run measurement accumulated from the `assistant` stream lines: the
116
+ * first-turn context baseline, turn/tool-call counts, and per-skill token
117
+ * attribution (see `run-telemetry.ts`). Present whenever the stream was
118
+ * consumed at all — including the `streamIncomplete` return shape — and
119
+ * carried as `partialTelemetry` on the thrown error types. Always a lower
120
+ * bound on output tokens for the same reason `modelUsage` is (see above).
121
+ */
122
+ telemetry?: RunTelemetry;
123
+ }
124
+
125
+ /**
126
+ * Callback fired as each new assistant turn/message completes (a new distinct
127
+ * `message.id` is observed), receiving the running accumulated per-model total.
128
+ * The passed map is a fresh snapshot — safe to retain without it mutating.
129
+ *
130
+ * The optional second argument is a generic progress phase (see
131
+ * `@app-vitals/agent-lib/progress-phases`) derived from the tool_use/thinking/text
132
+ * blocks in `message.content` — fired whenever the phase changes from the
133
+ * last one fired for this run (see `_consumeStream`'s phase-collapse logic).
134
+ * Purely additive: existing single-arg callbacks keep typechecking unchanged.
135
+ */
136
+ export type ProgressCallback = (
137
+ modelUsage: ModelUsage,
138
+ phase?: ProgressPhase,
139
+ ) => void;
140
+
141
+ /**
142
+ * Callback fired the instant a session id is captured off the stream's
143
+ * leading `system`/`init` line — the earliest point in a run a session id is
144
+ * ever available, strictly before any `ProgressCallback` fire (those only
145
+ * happen on later `assistant` lines). Exists so a caller (and `_runClaude`'s
146
+ * own retry gate, see below) can learn "this run now has an id worth
147
+ * resuming with" without waiting for a terminal result or a thrown error to
148
+ * carry it.
149
+ */
150
+ export type EarlySessionIdCallback = (sessionId: string) => void;
151
+
152
+ /**
153
+ * Returns the model name with the highest outputTokens from the CLI's
154
+ * modelUsage map. Returns undefined when the map is empty.
155
+ */
156
+ export function dominantModel(modelUsage: ModelUsage): string | undefined {
157
+ let best: string | undefined;
158
+ let bestTokens = -1;
159
+ for (const [model, usage] of Object.entries(modelUsage)) {
160
+ if (usage.outputTokens > bestTokens) {
161
+ bestTokens = usage.outputTokens;
162
+ best = model;
163
+ }
164
+ }
165
+ return best;
166
+ }
167
+
168
+ /**
169
+ * The terminal `result` stream event. Its fields are byte-identical in shape to
170
+ * what the old `--output-format json` single-blob mode returned, plus the
171
+ * stream discriminator (`type`/`subtype`).
172
+ */
173
+ interface ClaudeResultEvent {
174
+ type: "result";
175
+ subtype?: string;
176
+ result: string;
177
+ session_id: string;
178
+ is_error: boolean;
179
+ api_error_status?: number;
180
+ usage?: TokenUsage;
181
+ total_cost_usd?: number;
182
+ modelUsage?: ModelUsage;
183
+ }
184
+
185
+ /** One `assistant` stream event — carries a turn's usage keyed by message id. */
186
+ interface ClaudeAssistantEvent {
187
+ type: "assistant";
188
+ message: {
189
+ id: string;
190
+ role?: string;
191
+ model?: string;
192
+ usage?: TokenUsage;
193
+ content?: ContentBlock[];
194
+ };
195
+ /** Set on sub-agent turns: the parent session's `Agent` tool_use id. */
196
+ parent_tool_use_id?: string | null;
197
+ }
198
+
199
+ export class ClaudeRunError extends Error {
200
+ constructor(
201
+ message: string,
202
+ readonly apiErrorStatus: number | undefined,
203
+ readonly resultMessage: string,
204
+ readonly sessionId: string | undefined,
205
+ /** Accumulated per-model usage at the point of failure, if any. */
206
+ readonly modelUsage?: ModelUsage,
207
+ /** Telemetry accumulated before the failure, if the stream was consumed. */
208
+ readonly partialTelemetry?: RunTelemetry,
209
+ ) {
210
+ super(message);
211
+ this.name = "ClaudeRunError";
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Substring match against the Claude CLI's own error text for a transient
217
+ * network drop between the CLI process and the Anthropic API mid-stream
218
+ * (Sentry issue 7616867192 / SENTRY-47, e.g. "API Error: Connection lost
219
+ * mid-response. The response above may be incomplete.") — distinct from
220
+ * other `is_error` failures (auth, rate limits, etc.), which are not
221
+ * eligible for `_spawn`'s single connection-lost retry.
222
+ */
223
+ const CONNECTION_LOST_MID_RESPONSE = "Connection lost mid-response";
224
+
225
+ function _isConnectionLostMidResponse(err: unknown): err is ClaudeRunError {
226
+ return (
227
+ err instanceof ClaudeRunError &&
228
+ err.resultMessage.includes(CONNECTION_LOST_MID_RESPONSE)
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Thrown when a run is cancelled via its AbortSignal (e.g. a chat cancel
234
+ * request surfaced through the heartbeat tick). Mirrors ClaudeTimeoutError /
235
+ * ClaudeRunError's shape — it carries whatever session id the killed run
236
+ * managed to capture so the session stays resumable (see
237
+ * _saveSessionFromError). An aborted run is NEVER retried.
238
+ */
239
+ export class ClaudeAbortedError extends Error {
240
+ constructor(
241
+ /** Session id captured off the leading system/init line, if one arrived. */
242
+ readonly sessionId?: string,
243
+ /** Per-model usage accumulated before the process was killed, if any. */
244
+ readonly partialModelUsage?: ModelUsage,
245
+ /** Telemetry accumulated before the process was killed, if any. */
246
+ readonly partialTelemetry?: RunTelemetry,
247
+ ) {
248
+ super("Claude session was cancelled");
249
+ this.name = "ClaudeAbortedError";
250
+ }
251
+ }
252
+
253
+ /** Which timer fired: idle-reset (no stdout activity) or the hard ceiling. */
254
+ type ClaudeTimeoutReason = "idle" | "ceiling";
255
+
256
+ export class ClaudeTimeoutError extends Error {
257
+ constructor(
258
+ readonly timeoutMs: number,
259
+ /** Which limit fired — idle-reset timer or the hard ceiling. */
260
+ readonly reason: ClaudeTimeoutReason = "ceiling",
261
+ /**
262
+ * Per-model usage accumulated before the process was killed, if any.
263
+ * Always partial by definition — a clean finish never reaches this path.
264
+ */
265
+ readonly partialModelUsage?: ModelUsage,
266
+ /**
267
+ * The session id captured off the leading `system`/`init` stream-json
268
+ * line, if one arrived before the timeout fired. A timed-out run never
269
+ * reaches a terminal `result` event (the only other source of a session
270
+ * id), so this is the only way a timeout can still carry one.
271
+ */
272
+ readonly sessionId?: string,
273
+ /** Telemetry accumulated before the process was killed, if any. */
274
+ readonly partialTelemetry?: RunTelemetry,
275
+ ) {
276
+ super(`Claude session timed out after ${timeoutMs / 1000}s (${reason})`);
277
+ this.name = "ClaudeTimeoutError";
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Reports an error to Sentry, downgrading an expected-ceiling
283
+ * `ClaudeTimeoutError` — the intentional 1hr hard-ceiling backstop firing on
284
+ * a legitimately long-running session, not a defect — to a `captureMessage`
285
+ * so it stays visible without creating an actionable Sentry Issue (VITALS-
286
+ * OS-46: this previously kept re-triggering error-patrol as "regressed" even
287
+ * though nothing was broken). A `reason: "idle"` timeout (a genuine hang) and
288
+ * any other error are reported unchanged via `captureException`.
289
+ *
290
+ * Every call site that can receive a thrown `ClaudeTimeoutError` should
291
+ * report through this helper instead of calling `captureException` directly.
292
+ */
293
+ export function reportClaudeError(
294
+ sentryClient: ErrorCapturingClient | undefined,
295
+ err: unknown,
296
+ ): void {
297
+ if (err instanceof ClaudeTimeoutError && err.reason === "ceiling") {
298
+ sentryClient?.captureMessage?.(
299
+ `Claude session hit ceiling timeout after ${
300
+ err.timeoutMs / 1000
301
+ }s (expected backstop, not a bug)`,
302
+ );
303
+ return;
304
+ }
305
+ sentryClient?.captureException(err);
306
+ }
307
+
308
+ interface ClaudeSessionStore {
309
+ get: (key: string) => Promise<string | undefined> | string | undefined;
310
+ set: (key: string, id: string) => Promise<void> | void;
311
+ clear?: (key: string) => Promise<void> | void;
312
+ }
313
+
314
+ /**
315
+ * Create a claude CLI runner.
316
+ *
317
+ * Default parameter values:
318
+ * - workspace: process.cwd() — caller should inject the real workspace path
319
+ * - sessions: no-op store — caller should inject a real createFileSessionStore instance
320
+ * - model: undefined — falls back to liveClaudeConfig.model
321
+ */
322
+ export function createRunClaude(
323
+ spawner: typeof Bun.spawn = Bun.spawn,
324
+ sessions: ClaudeSessionStore = { get: () => undefined, set: () => {} },
325
+ model: string | undefined = undefined,
326
+ workspace: string = process.cwd(),
327
+ sentryClient?: ErrorCapturingClient,
328
+ extraAllowedTools: string[] | undefined = undefined,
329
+ fallbackModel: string | undefined = undefined,
330
+ effortLevel: string | undefined = undefined,
331
+ timeoutMs: number = 30 * 60 * 1000,
332
+ /**
333
+ * Idle-reset timeout: cleared and restarted on every stdout line. Fires
334
+ * when the process goes silent for this long, even if `timeoutMs` (the
335
+ * hard ceiling) hasn't elapsed yet. Default 25min.
336
+ */
337
+ idleTimeoutMs: number = 25 * 60 * 1000,
338
+ onProgress: ProgressCallback | undefined = undefined,
339
+ ): (
340
+ message: string,
341
+ sessionKey?: string,
342
+ onProgress?: ProgressCallback,
343
+ signal?: AbortSignal,
344
+ extraEnv?: Record<string, string>,
345
+ onEarlySessionId?: EarlySessionIdCallback,
346
+ ) => Promise<ClaudeRunResult> {
347
+ // Per-session queue: ensures messages on the same thread run serially
348
+ const sessionQueues = new Map<string, Promise<unknown>>();
349
+
350
+ function _enqueue<T>(key: string, fn: () => Promise<T>): Promise<T> {
351
+ const prev = sessionQueues.get(key) ?? Promise.resolve();
352
+ const next = prev
353
+ .then(
354
+ () => fn(),
355
+ () => fn(),
356
+ )
357
+ .finally(() => {
358
+ if (sessionQueues.get(key) === next) sessionQueues.delete(key);
359
+ });
360
+ sessionQueues.set(key, next);
361
+ return next as Promise<T>;
362
+ }
363
+
364
+ function _buildArgs(
365
+ message: string,
366
+ resumeSessionId: string | undefined,
367
+ ): string[] {
368
+ const resolvedModel = model ?? liveClaudeConfig.model;
369
+ const resolvedFallbackModel =
370
+ fallbackModel ?? liveClaudeConfig.fallbackModel;
371
+ const resolvedEffortLevel = effortLevel ?? liveClaudeConfig.effortLevel;
372
+ const resolvedExtraAllowedTools =
373
+ extraAllowedTools ?? liveClaudeConfig.allowedTools;
374
+
375
+ // Deduplicate tools: Set preserves insertion order, so first-occurrence wins
376
+ const deduplicatedTools = [
377
+ ...new Set([...FLOOR_TOOLS, ...resolvedExtraAllowedTools]),
378
+ ];
379
+
380
+ const args = [
381
+ "-p",
382
+ "--output-format",
383
+ "stream-json",
384
+ "--verbose",
385
+ "--permission-mode",
386
+ "acceptEdits",
387
+ "--allowedTools",
388
+ ...deduplicatedTools,
389
+ "--model",
390
+ resolvedModel,
391
+ ...(resolvedFallbackModel
392
+ ? ["--fallback-model", resolvedFallbackModel]
393
+ : []),
394
+ ...(resolvedEffortLevel ? ["--effort", resolvedEffortLevel] : []),
395
+ ];
396
+
397
+ if (resumeSessionId) {
398
+ args.push("-r", resumeSessionId);
399
+ }
400
+
401
+ args.push(message);
402
+ return args;
403
+ }
404
+
405
+ /** Deep-clone a ModelUsage map so callbacks get an immutable snapshot. */
406
+ function _snapshotUsage(usage: ModelUsage): ModelUsage {
407
+ const out: ModelUsage = {};
408
+ for (const [model, entry] of Object.entries(usage)) {
409
+ out[model] = { ...entry };
410
+ }
411
+ return out;
412
+ }
413
+
414
+ /**
415
+ * Consume a stream-json NDJSON stdout stream incrementally, accumulating
416
+ * per-model usage from `assistant` lines (deduped by message id) and
417
+ * capturing the terminal `result` event if one arrives. Malformed / non-JSON
418
+ * lines are skipped rather than aborting the whole parse.
419
+ */
420
+ async function _consumeStream(
421
+ stream: ReadableStream<Uint8Array>,
422
+ onLine?: () => void,
423
+ perCallOnProgress?: ProgressCallback,
424
+ onEarlySessionId?: EarlySessionIdCallback,
425
+ ): Promise<{
426
+ result?: ClaudeResultEvent;
427
+ modelUsage: ModelUsage;
428
+ raw: string;
429
+ sessionId?: string;
430
+ telemetry: RunTelemetry;
431
+ }> {
432
+ const reader = stream.getReader();
433
+ const decoder = new TextDecoder();
434
+ const accumulated: ModelUsage = {};
435
+ const seenMessageIds = new Set<string>();
436
+ const telemetry = new RunTelemetryAccumulator();
437
+ let result: ClaudeResultEvent | undefined;
438
+ let buffer = "";
439
+ let raw = "";
440
+ // Last phase fired for THIS _consumeStream call (i.e. this one runClaude
441
+ // invocation) — reset per call, never global/module state. Used to
442
+ // collapse repeated identical phases to a single onProgress fire.
443
+ let lastFiredPhase: ProgressPhase | undefined;
444
+ // Captured off the very first `system`/`init` line, if one arrives — the
445
+ // earliest point in the stream a session id is ever available. Populated
446
+ // once and never overwritten, so a stray later system line (there's never
447
+ // more than one `init` per run in practice) can't clobber it.
448
+ let earlySessionId: string | undefined;
449
+
450
+ const handleLine = (raw: string) => {
451
+ const line = raw.trim();
452
+ if (!line) return;
453
+ onLine?.();
454
+ let parsed: unknown;
455
+ try {
456
+ parsed = JSON.parse(line);
457
+ } catch {
458
+ return; // skip stray non-JSON lines without losing prior accumulation
459
+ }
460
+ if (!parsed || typeof parsed !== "object") return;
461
+ const event = parsed as { type?: string };
462
+
463
+ if (event.type === "result") {
464
+ result = parsed as ClaudeResultEvent;
465
+ return;
466
+ }
467
+ if (event.type === "system") {
468
+ const system = parsed as {
469
+ subtype?: string;
470
+ session_id?: string;
471
+ };
472
+ if (
473
+ earlySessionId === undefined &&
474
+ system.subtype === "init" &&
475
+ system.session_id
476
+ ) {
477
+ earlySessionId = system.session_id;
478
+ // Fired here, on the leading init line — before any assistant-turn
479
+ // onProgress fire below, since those only happen on later lines.
480
+ onEarlySessionId?.(earlySessionId);
481
+ }
482
+ return;
483
+ }
484
+ if (event.type !== "assistant") return; // ignore user/etc.
485
+
486
+ const { message, parent_tool_use_id: parentToolUseId } =
487
+ parsed as ClaudeAssistantEvent;
488
+
489
+ // Tool-call counting and Skill/Agent attribution switches happen on
490
+ // every line (deduped by tool_use id inside the accumulator), before
491
+ // the usage guard — a tool_use-only line carries no `usage` yet.
492
+ telemetry.observeContent(message?.content);
493
+
494
+ // Walk message.content for a progress phase BEFORE the usage guard
495
+ // below, so a tool_use-only line (no `usage` field yet) can still fire
496
+ // a phase update. Each block that maps to a phase can fire its own
497
+ // onProgress call — but repeated identical phases collapse to a single
498
+ // fire (only fires when the phase differs from the last one fired this
499
+ // run), so e.g. two consecutive Read blocks fire "reading" once.
500
+ if (Array.isArray(message?.content)) {
501
+ for (const block of message.content) {
502
+ const phase = phaseForBlock(block);
503
+ if (phase === undefined || phase === lastFiredPhase) continue;
504
+ lastFiredPhase = phase;
505
+ onProgress?.(_snapshotUsage(accumulated), phase);
506
+ perCallOnProgress?.(_snapshotUsage(accumulated), phase);
507
+ }
508
+ }
509
+
510
+ if (!message?.id || !message.usage) return;
511
+ if (seenMessageIds.has(message.id)) return; // dedupe repeated turn lines
512
+ seenMessageIds.add(message.id);
513
+
514
+ const model = message.model ?? "unknown";
515
+ telemetry.observeUsage(
516
+ message.id,
517
+ model,
518
+ message.usage,
519
+ parentToolUseId ?? undefined,
520
+ );
521
+ let entry = accumulated[model];
522
+ if (!entry) {
523
+ entry = {
524
+ inputTokens: 0,
525
+ outputTokens: 0,
526
+ cacheReadInputTokens: 0,
527
+ cacheCreationInputTokens: 0,
528
+ costUSD: 0, // authoritative cost is only known from the result event
529
+ };
530
+ accumulated[model] = entry;
531
+ }
532
+ entry.inputTokens += message.usage.input_tokens ?? 0;
533
+ // NOTE: message.usage.output_tokens excludes extended-thinking tokens
534
+ // (billed as output, but the thinking content is often redacted before
535
+ // it reaches this line) — this accumulation undercounts true output
536
+ // tokens whenever the `result` event isn't reached. See the
537
+ // `streamIncomplete` doc comment above for the full explanation.
538
+ entry.outputTokens += message.usage.output_tokens ?? 0;
539
+ entry.cacheReadInputTokens += message.usage.cache_read_input_tokens ?? 0;
540
+ entry.cacheCreationInputTokens +=
541
+ message.usage.cache_creation_input_tokens ?? 0;
542
+
543
+ // Fire both the construction-time-bound closure (kept for back-compat)
544
+ // and the per-call callback (if provided), each with its own snapshot
545
+ // so neither can mutate what the other observes.
546
+ onProgress?.(_snapshotUsage(accumulated));
547
+ perCallOnProgress?.(_snapshotUsage(accumulated));
548
+ };
549
+
550
+ try {
551
+ while (true) {
552
+ const { done, value } = await reader.read();
553
+ if (done) break;
554
+ const chunk = decoder.decode(value, { stream: true });
555
+ raw += chunk;
556
+ buffer += chunk;
557
+ let nl = buffer.indexOf("\n");
558
+ while (nl !== -1) {
559
+ handleLine(buffer.slice(0, nl));
560
+ buffer = buffer.slice(nl + 1);
561
+ nl = buffer.indexOf("\n");
562
+ }
563
+ }
564
+ const tail = decoder.decode();
565
+ raw += tail;
566
+ buffer += tail;
567
+ handleLine(buffer); // trailing line without a newline
568
+ } finally {
569
+ reader.releaseLock();
570
+ }
571
+
572
+ return {
573
+ result,
574
+ modelUsage: accumulated,
575
+ raw,
576
+ sessionId: earlySessionId,
577
+ telemetry: telemetry.snapshot(),
578
+ };
579
+ }
580
+
581
+ async function _spawnOnce(
582
+ args: string[],
583
+ perCallOnProgress?: ProgressCallback,
584
+ signal?: AbortSignal,
585
+ extraEnv?: Record<string, string>,
586
+ onEarlySessionId?: EarlySessionIdCallback,
587
+ ): Promise<ClaudeRunResult> {
588
+ // Strip SENTRY_DSN so a spawned Claude Code session (and any `bun test`
589
+ // it runs internally) can never construct a real Sentry client from
590
+ // ambient env — this is not the agent's own operational Sentry
591
+ // reporting (see the reportCronFailure / captureException call sites),
592
+ // which runs in this parent process against its own client, unaffected
593
+ // by the child's env.
594
+ const { SENTRY_DSN: _sentryDsn, ...spawnEnv } = process.env;
595
+ // extraEnv is layered on last (after the SENTRY_DSN strip) as a per-run
596
+ // override point — if a caller somehow passed SENTRY_DSN in extraEnv, it
597
+ // would deliberately win.
598
+ //
599
+ // Nothing else is filtered: the CLI (and every install/build it runs in
600
+ // turn) inherits the ambient environment wholesale, so tooling cache vars
601
+ // like npm_config_cache / YARN_CACHE_FOLDER / PIP_CACHE_DIR reach child
602
+ // install processes intact. See plugins/shipwright/references/
603
+ // toolchain-patterns.md ("Environment Passthrough to Install Children")
604
+ // for the one place that is NOT true — a target repo whose task runner
605
+ // enforces a strict env allowlist.
606
+ const spawnEnvironment = { ...spawnEnv, ...extraEnv };
607
+ // Two literal option objects rather than one spread: the "pipe" literals
608
+ // have to stay literal for Bun.spawn's overloads to type stdout/stderr as
609
+ // readable streams rather than the widened number|stream|undefined union.
610
+ const spawnClaude = (cgroup: string | undefined) =>
611
+ cgroup === undefined
612
+ ? spawner(["claude", ...args], {
613
+ cwd: workspace,
614
+ env: spawnEnvironment,
615
+ stdout: "pipe",
616
+ stderr: "pipe",
617
+ })
618
+ : spawner(["claude", ...args], {
619
+ cwd: workspace,
620
+ env: spawnEnvironment,
621
+ stdout: "pipe",
622
+ stderr: "pipe",
623
+ cgroup,
624
+ });
625
+
626
+ // Put the CLI in its own cgroup when the container delegates cgroup v2,
627
+ // so the timeout/abort path can kill it and every process it forked in
628
+ // one atomic write. Returns undefined on the (common) read-only-cgroupfs
629
+ // container, where killTree falls back to a procfs descendant walk
630
+ // instead — see agent/src/runtime/process-tree-kill.ts.
631
+ let cgroupPath = createRunCgroup();
632
+ let proc: ReturnType<typeof spawnClaude>;
633
+ try {
634
+ proc = spawnClaude(cgroupPath);
635
+ } catch (err) {
636
+ // Bun fails the spawn outright if the cgroup can't be joined (it was
637
+ // removed underneath us, delegation was revoked mid-run, …). A
638
+ // containment optimization must never cost us the run itself: drop the
639
+ // cgroup, latch the mode off for later invocations, and spawn plainly.
640
+ if (!cgroupPath) throw err;
641
+ removeRunCgroup(cgroupPath);
642
+ markCgroupUnavailable(
643
+ `spawn with cgroup failed: ${err instanceof Error ? err.message : String(err)}`,
644
+ );
645
+ cgroupPath = undefined;
646
+ proc = spawnClaude(undefined);
647
+ }
648
+
649
+ let timedOut = false;
650
+ let aborted = false;
651
+ let timeoutReason: ClaudeTimeoutReason = "ceiling";
652
+ let firedTimeoutMs = timeoutMs;
653
+
654
+ // Cancel trigger: a third way to kill(), beside the two timeout timers. If
655
+ // the signal is already aborted, kill immediately; otherwise arm a listener
656
+ // that is REMOVED in the .finally() below (beside the two clearTimeouts) so
657
+ // it can't leak on a long-lived, per-thread signal across a resume-retry.
658
+ const onAbort = () => {
659
+ aborted = true;
660
+ killTree(proc, cgroupPath);
661
+ };
662
+ if (signal) {
663
+ if (signal.aborted) onAbort();
664
+ else signal.addEventListener("abort", onAbort);
665
+ }
666
+
667
+ // Hard ceiling: set once, never reset. Backstops a continuously-active
668
+ // but never-converging session (idle timer alone would never fire).
669
+ const ceilingTimer = setTimeout(() => {
670
+ timedOut = true;
671
+ timeoutReason = "ceiling";
672
+ firedTimeoutMs = timeoutMs;
673
+ killTree(proc, cgroupPath);
674
+ }, timeoutMs);
675
+
676
+ // Idle-reset timer: cleared/restarted on every stdout line. Fires when
677
+ // the process goes silent, even with ceiling headroom remaining.
678
+ let idleTimer: ReturnType<typeof setTimeout>;
679
+ const resetIdleTimer = () => {
680
+ clearTimeout(idleTimer);
681
+ idleTimer = setTimeout(() => {
682
+ timedOut = true;
683
+ timeoutReason = "idle";
684
+ firedTimeoutMs = idleTimeoutMs;
685
+ killTree(proc, cgroupPath);
686
+ }, idleTimeoutMs);
687
+ };
688
+ resetIdleTimer();
689
+
690
+ const [
691
+ {
692
+ result,
693
+ modelUsage,
694
+ raw,
695
+ sessionId: earlySessionId,
696
+ telemetry: rawTelemetry,
697
+ },
698
+ stderr,
699
+ exitCode,
700
+ ] = await Promise.all([
701
+ _consumeStream(
702
+ proc.stdout,
703
+ resetIdleTimer,
704
+ perCallOnProgress,
705
+ onEarlySessionId,
706
+ ),
707
+ new Response(proc.stderr).text(),
708
+ proc.exited,
709
+ ]).finally(() => {
710
+ clearTimeout(ceilingTimer);
711
+ clearTimeout(idleTimer);
712
+ if (signal) signal.removeEventListener("abort", onAbort);
713
+ // The CLI has exited by now, so this is also the point where any
714
+ // descendant that outlived it becomes visible: removeRunCgroup kills
715
+ // stragglers still inside before removing the directory. Best-effort —
716
+ // it never throws.
717
+ if (cgroupPath) removeRunCgroup(cgroupPath);
718
+ });
719
+
720
+ // A resumed session (`-r`) replays prior conversation into its first
721
+ // turn, so that turn is not a cold always-loaded-context baseline. Keep
722
+ // the rest of the telemetry (turns, tool calls, attribution) but drop
723
+ // the baseline so the admin series only ever contains fresh-session
724
+ // numbers.
725
+ const { firstTurn: _firstTurn, ...resumedTelemetry } = rawTelemetry;
726
+ const telemetry: RunTelemetry = args.includes("-r")
727
+ ? resumedTelemetry
728
+ : rawTelemetry;
729
+
730
+ // Aborted must be checked BEFORE timedOut and the exitCode checks: kill()
731
+ // yields exit 143, which the exitCode !== 0 branch would otherwise
732
+ // misclassify as a normal run failure.
733
+ if (aborted) {
734
+ throw new ClaudeAbortedError(earlySessionId, modelUsage, telemetry);
735
+ }
736
+
737
+ if (timedOut) {
738
+ throw new ClaudeTimeoutError(
739
+ firedTimeoutMs,
740
+ timeoutReason,
741
+ modelUsage,
742
+ earlySessionId,
743
+ telemetry,
744
+ );
745
+ }
746
+
747
+ if (exitCode !== 0) {
748
+ if (result?.is_error) {
749
+ throw new ClaudeRunError(
750
+ `claude exited ${exitCode}: api_error_status=${result.api_error_status ?? "unknown"} ${result.result}`,
751
+ result.api_error_status,
752
+ result.result,
753
+ result.session_id,
754
+ result.modelUsage ?? modelUsage,
755
+ telemetry,
756
+ );
757
+ }
758
+ // A truncated stream that still carried some usage: surface it on the
759
+ // error rather than discarding it.
760
+ if (Object.keys(modelUsage).length > 0) {
761
+ const diagnostic = stderr.trim() || "stream truncated";
762
+ throw new ClaudeRunError(
763
+ `claude exited ${exitCode}: ${diagnostic}`,
764
+ undefined,
765
+ diagnostic,
766
+ result?.session_id ?? earlySessionId,
767
+ modelUsage,
768
+ telemetry,
769
+ );
770
+ }
771
+ throw new Error(
772
+ `claude exited ${exitCode}: ${stderr.trim() || raw.trim()}`,
773
+ );
774
+ }
775
+
776
+ if (!result) {
777
+ // Clean exit, but the stream ended without a terminal result event.
778
+ // Surface the accumulated partial usage via a distinct return shape
779
+ // instead of throwing everything away.
780
+ return {
781
+ result: "",
782
+ sessionId: earlySessionId,
783
+ modelUsage,
784
+ streamIncomplete: true,
785
+ telemetry,
786
+ };
787
+ }
788
+ if (result.is_error) {
789
+ throw new ClaudeRunError(
790
+ `claude error: ${result.result}`,
791
+ result.api_error_status,
792
+ result.result,
793
+ result.session_id,
794
+ result.modelUsage ?? modelUsage,
795
+ telemetry,
796
+ );
797
+ }
798
+
799
+ return {
800
+ result: result.result,
801
+ sessionId: result.session_id,
802
+ usage: result.usage,
803
+ totalCostUsd: result.total_cost_usd,
804
+ modelUsage: result.modelUsage,
805
+ telemetry,
806
+ };
807
+ }
808
+
809
+ /**
810
+ * Thin wrapper around `_spawnOnce` that retries exactly once on a
811
+ * connection-lost-mid-response failure — a transient network drop between
812
+ * the Claude Code CLI process and the Anthropic API, external to this
813
+ * codebase (Sentry issue 7616867192 / SENTRY-47). Human decision (Dave,
814
+ * 2026-09-08): retry ONCE, discarding the partial response and re-running
815
+ * the whole spawn from scratch (never resuming it) — no further retries.
816
+ * Accepts the small duplicate-side-effect risk; logs the retry via Sentry
817
+ * so it stays visible. Any other failure (including a second
818
+ * connection-lost error on the retry itself) propagates unchanged.
819
+ */
820
+ async function _spawn(
821
+ args: string[],
822
+ perCallOnProgress?: ProgressCallback,
823
+ signal?: AbortSignal,
824
+ extraEnv?: Record<string, string>,
825
+ onEarlySessionId?: EarlySessionIdCallback,
826
+ ): Promise<ClaudeRunResult> {
827
+ try {
828
+ return await _spawnOnce(
829
+ args,
830
+ perCallOnProgress,
831
+ signal,
832
+ extraEnv,
833
+ onEarlySessionId,
834
+ );
835
+ } catch (err) {
836
+ if (!_isConnectionLostMidResponse(err)) throw err;
837
+ sentryClient?.captureMessage?.(
838
+ `Claude spawn: Connection lost mid-response — retrying once (session ${
839
+ err.sessionId ?? "unknown"
840
+ })`,
841
+ );
842
+ return _spawnOnce(
843
+ args,
844
+ perCallOnProgress,
845
+ signal,
846
+ extraEnv,
847
+ onEarlySessionId,
848
+ );
849
+ }
850
+ }
851
+
852
+ async function _saveSession(
853
+ sessionKey: string | undefined,
854
+ output: ClaudeRunResult,
855
+ ) {
856
+ if (sessionKey && output.sessionId) {
857
+ await sessions.set(sessionKey, output.sessionId);
858
+ }
859
+ }
860
+
861
+ /**
862
+ * Persist whatever session id a FAILED attempt still managed to capture.
863
+ * ClaudeRunError/ClaudeTimeoutError can both carry a `sessionId` — the CLI
864
+ * may have already created a real session (and transcript file) before
865
+ * erroring or timing out. Without this, a first-message failure leaves
866
+ * `sessions` empty even though a resumable session exists, so the next
867
+ * reply on the same Slack thread starts a brand-new, context-free session
868
+ * instead of resuming. Purely a side effect — never affects control flow.
869
+ */
870
+ async function _saveSessionFromError(
871
+ sessionKey: string | undefined,
872
+ err: unknown,
873
+ ) {
874
+ if (!sessionKey) return;
875
+ if (
876
+ !(
877
+ err instanceof ClaudeRunError ||
878
+ err instanceof ClaudeTimeoutError ||
879
+ err instanceof ClaudeAbortedError
880
+ )
881
+ )
882
+ return;
883
+ if (err.sessionId) {
884
+ await sessions.set(sessionKey, err.sessionId);
885
+ }
886
+ }
887
+
888
+ async function _runClaude(
889
+ message: string,
890
+ sessionKey: string | undefined,
891
+ perCallOnProgress: ProgressCallback | undefined,
892
+ signal: AbortSignal | undefined,
893
+ extraEnv?: Record<string, string>,
894
+ onEarlySessionId?: EarlySessionIdCallback,
895
+ ): Promise<ClaudeRunResult> {
896
+ const existingSessionId = sessionKey
897
+ ? await sessions.get(sessionKey)
898
+ : undefined;
899
+
900
+ const args = _buildArgs(message, existingSessionId);
901
+
902
+ // A call that starts FRESH (no existingSessionId) can still capture a
903
+ // session id mid-attempt — off the stream's leading system/init line,
904
+ // well before any terminal result or thrown error. Track it here so the
905
+ // retry gate below can resume with it even though the run didn't begin
906
+ // as a resume. `??=` keeps first-write-wins, mirroring _consumeStream's
907
+ // own earlySessionId semantics; the caller's own callback still fires on
908
+ // every capture (including a retry attempt), not just the first.
909
+ let capturedSessionId: string | undefined;
910
+ const captureEarlySessionId: EarlySessionIdCallback = (sessionId) => {
911
+ capturedSessionId ??= sessionId;
912
+ onEarlySessionId?.(sessionId);
913
+ // Persist immediately, not just at settle time (_saveSession /
914
+ // _saveSessionFromError below). If the whole agent process dies before
915
+ // this call settles (OOM, pod restart, redeploy) — not just the
916
+ // spawned `claude` CLI child being killed — a settle-time-only write
917
+ // would leave `sessions` empty even though the CLI already created a
918
+ // durably resumable session. Fire-and-forget, failure swallowed —
919
+ // best-effort persistence, same contract as _saveSessionFromError: a
920
+ // failed write here must never mask or affect control flow. The
921
+ // redundant write this causes at settle time is expected and safe —
922
+ // sessions.ts's per-key enqueue() serializes same-key writes,
923
+ // last-write-wins.
924
+ if (sessionKey) {
925
+ try {
926
+ Promise.resolve(sessions.set(sessionKey, sessionId)).catch(() => {});
927
+ } catch {
928
+ // Best-effort persistence — a synchronous throw from a misbehaving
929
+ // store must never mask or affect control flow either, same as the
930
+ // async-rejection case above.
931
+ }
932
+ }
933
+ };
934
+
935
+ try {
936
+ const output = await _spawn(
937
+ args,
938
+ perCallOnProgress,
939
+ signal,
940
+ extraEnv,
941
+ captureEarlySessionId,
942
+ );
943
+ await _saveSession(sessionKey, output);
944
+ return output;
945
+ } catch (err) {
946
+ // The initial attempt failed — save whatever session id it captured
947
+ // before deciding whether to retry, so even a non-retried (or
948
+ // retry-ineligible) failure still leaves a resumable mapping behind.
949
+ try {
950
+ await _saveSessionFromError(sessionKey, err);
951
+ } catch {
952
+ // Best-effort persistence — a failed write here must never mask or
953
+ // replace the original Claude error being handled in this catch block.
954
+ }
955
+
956
+ // A session id worth resuming with, in priority order: one already
957
+ // known at call start (Slack-thread resume), one captured mid-attempt
958
+ // off the init line even though this call started fresh, or one the
959
+ // thrown error itself carries (ClaudeRunError/ClaudeTimeoutError/
960
+ // ClaudeAbortedError all expose `sessionId`). Without at least one of
961
+ // these there's nothing to resume, so no retry — same as today's
962
+ // behavior when existingSessionId is undefined.
963
+ const errSessionId =
964
+ err instanceof ClaudeRunError ||
965
+ err instanceof ClaudeTimeoutError ||
966
+ err instanceof ClaudeAbortedError
967
+ ? err.sessionId
968
+ : undefined;
969
+ const retrySessionId =
970
+ existingSessionId ?? capturedSessionId ?? errSessionId;
971
+
972
+ // Retry once: transient blips (e.g. a socket close, or the hard
973
+ // wall-clock ceiling firing on a session that was still legitimately
974
+ // working) can self-heal on a second attempt without losing
975
+ // conversation context. Do NOT retry ClaudeAbortedError — the user
976
+ // asked to cancel, so spawning a second process would defy that. Do
977
+ // NOT retry an idle-reason ClaudeTimeoutError — that means the session
978
+ // genuinely hung, and we should surface the error rather than silently
979
+ // spawning a second process that would also hang; a ceiling-reason
980
+ // timeout is retried, since it's the expected "hit the hard cap while
981
+ // still working" case, not a hang signal. If the retry also fails,
982
+ // rethrow the ORIGINAL error and leave the session mapping untouched —
983
+ // an error (even a burst of them) is never treated as proof the
984
+ // session itself is corrupt.
985
+ // Also exclude a connection-lost-mid-response failure: `_spawn` already
986
+ // gave it its own dedicated single retry (see `_spawn`'s docstring —
987
+ // Dave, 2026-09-08: retry ONCE, no further retries). That error's
988
+ // ClaudeRunError happens to carry a `sessionId` (from the CLI's
989
+ // `result` event) like any other, so without this exclusion it would
990
+ // qualify for a SECOND, stacked retry cycle here — silently doubling
991
+ // the documented one-retry limit.
992
+ const retryBlocked =
993
+ err instanceof ClaudeAbortedError ||
994
+ (err instanceof ClaudeTimeoutError && err.reason === "idle") ||
995
+ _isConnectionLostMidResponse(err);
996
+
997
+ if (retrySessionId && !retryBlocked) {
998
+ const retryArgs = _buildArgs(message, retrySessionId);
999
+ try {
1000
+ const output = await _spawn(
1001
+ retryArgs,
1002
+ perCallOnProgress,
1003
+ signal,
1004
+ extraEnv,
1005
+ captureEarlySessionId,
1006
+ );
1007
+ await _saveSession(sessionKey, output);
1008
+ return { ...output, recoveredFromError: true };
1009
+ } catch (retryErr) {
1010
+ // The retry also failed — overwrite with its own most-recently-known
1011
+ // session id (if any), then still rethrow the ORIGINAL error.
1012
+ try {
1013
+ await _saveSessionFromError(sessionKey, retryErr);
1014
+ } catch {
1015
+ // Best-effort persistence — a failed write here must never mask
1016
+ // the original error or prevent the Sentry capture/rethrow below.
1017
+ }
1018
+ sentryClient?.captureException(err);
1019
+ throw err;
1020
+ }
1021
+ }
1022
+ throw err;
1023
+ }
1024
+ }
1025
+
1026
+ return async function runClaude(
1027
+ message: string,
1028
+ sessionKey?: string,
1029
+ onProgress?: ProgressCallback,
1030
+ signal?: AbortSignal,
1031
+ extraEnv?: Record<string, string>,
1032
+ onEarlySessionId?: EarlySessionIdCallback,
1033
+ ): Promise<ClaudeRunResult> {
1034
+ if (sessionKey)
1035
+ return _enqueue(sessionKey, () =>
1036
+ _runClaude(
1037
+ message,
1038
+ sessionKey,
1039
+ onProgress,
1040
+ signal,
1041
+ extraEnv,
1042
+ onEarlySessionId,
1043
+ ),
1044
+ );
1045
+ return _runClaude(
1046
+ message,
1047
+ undefined,
1048
+ onProgress,
1049
+ signal,
1050
+ extraEnv,
1051
+ onEarlySessionId,
1052
+ );
1053
+ };
1054
+ }