@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,617 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import { verifyClaudeSubagentVersion } from "./claude-subagents";
3
+ import { isRecord, providerArgsSelectStreamJson, type LaunchMode, type Message, type ProviderState } from "@humwick/sdk";
4
+ import { terminateProviderProcesses } from "../provider-processes";
5
+ import { createInterface } from "node:readline";
6
+ import { Readable } from "node:stream";
7
+ import type { DeliverResult, ManagedProcess, PromptOfferOptions, ProviderAdapter, ProviderConfig, ProviderDeliverContext, ProviderSessionEvent, ProviderSessionTurnContext, ProviderStatusUpdate, ProviderTokenUsageReporter, RunnerSpawnConfig, ShutdownOutcome, SpawnArgs } from "../adapter";
8
+ import { deliveredBatchReplyRoute } from "../reply-obligation-cache";
9
+ import { ClaudeAdapter } from "./claude";
10
+ import { claudeEffortSwitchMeta } from "./claude-effort";
11
+ import { claudeProviderMessageText } from "./claude-delivery";
12
+ import { ClaudeHeadlessMirror } from "./claude-headless-mirror";
13
+ import { claudeApiErrorOf, claudeRefusalHold, claudeResultFailure, claudeTurnError } from "./claude-headless-refusal";
14
+ import { claudePermissionPromptHandler } from "./claude-permission";
15
+ import { collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
16
+ import { claudeConfigHome, claudeTranscriptPath } from "./claude-session-probe";
17
+ import { ClaudeTranscriptTokenUsageCapture } from "./claude-token-usage-capture";
18
+ import { ProviderSubmitFailure } from "../provider-submit-failure";
19
+ import { claudeToolHookPaths } from "./claude-transcript";
20
+
21
+ interface HeadlessState {
22
+ child: Bun.Subprocess<"pipe", "pipe", "pipe">;
23
+ turnId?: string;
24
+ /** The turn the latest submit minted; a delivery reads it on resuming, after the turn may already have ended. */
25
+ submittedTurnId?: string;
26
+ replyRoute?: ProviderSessionEvent["replyRoute"];
27
+ /** Relay ids the runner already took as delivered for the live turn; a later refusal names them so the runner re-parks them. */
28
+ acked?: { turnId: string; ids: number[] };
29
+ stopping: boolean;
30
+ initialized: boolean;
31
+ submitConfirmed: boolean;
32
+ lastActivityAt: number;
33
+ unknown?: { since: number; timeoutMs: number };
34
+ receipt?: { resolve(): void; reject(error: Error): void; ready(): void; retrying(delayMs: number): void };
35
+ /** Pending stream-json control_request receipts, keyed by request_id. */
36
+ controlRequests: Map<string, (error?: Error, response?: Record<string, unknown>) => void>;
37
+ contextCommand?: ContextCommand;
38
+ interruptedTurnId?: string;
39
+ sessionId?: string;
40
+ /** Claude's own cwd and config home, from which its session transcript path follows. */
41
+ cwd?: string;
42
+ configHome: string;
43
+ mirror: ClaudeHeadlessMirror;
44
+ /** Context commands released by the watchdog; their late native results are not turn results. */
45
+ abandonedCommands: Set<string>;
46
+ /** The live turn's API-error class, from the synthetic error message that precedes its error result. */
47
+ apiError?: string;
48
+ /** Turn the provider refused under a rate-limit/quota/overload hold (#2771). */
49
+ refusedTurnId?: string;
50
+ /** Resolves a delivery waiting for the turn's first outcome: true if it was refused. */
51
+ refusalWatch?: { turnId: string; settle(refused: boolean): void };
52
+ }
53
+
54
+ /** A `/compact` or `/clear` slash command; only a native boundary event settles it as done. */
55
+ interface ContextCommand {
56
+ kind: "compact" | "clear";
57
+ uuid: string;
58
+ boundary?: Record<string, unknown>;
59
+ failure?: string;
60
+ sessionId?: string;
61
+ timedOut?: boolean;
62
+ /** The provider refused the command under a rate-limit/quota/overload wall. */
63
+ hold?: ProviderState;
64
+ settle(error?: Error): void;
65
+ }
66
+
67
+ const CONTROL_RECEIPT_TIMEOUT_MS = 10_000;
68
+ // Compaction is a full model summarization pass; large contexts take minutes.
69
+ const COMPACT_BOUNDARY_TIMEOUT_MS = 600_000;
70
+ const CLEAR_BOUNDARY_TIMEOUT_MS = 60_000;
71
+ // A refusal's synthetic error and result follow the input receipt within milliseconds; a later one reports through status only.
72
+ const REFUSAL_GRACE_MS = 400;
73
+ // After a boundary timeout the command is interrupted; this bounds how long input stays fenced for it to settle.
74
+ const CONTEXT_COMMAND_RECOVERY_MS = 30_000;
75
+
76
+ export function createClaudeTransportAdapter(provider: string, args: string[], launchMode?: LaunchMode): ProviderAdapter {
77
+ const rawStreamJson = providerArgsSelectStreamJson(args);
78
+ // The explicit launch mode wins; raw stream-json providerArgs remain the legacy way to select headless.
79
+ if (launchMode === "interactive" && rawStreamJson) throw new Error("launchMode=interactive conflicts with --input-format stream-json providerArgs");
80
+ const streaming = launchMode ? launchMode === "headless" : rawStreamJson;
81
+ return streaming ? new ClaudeHeadlessAdapter(provider) : new ClaudeAdapter({ provider });
82
+ }
83
+
84
+ /** Persistent native Claude print-mode transport (#2754, #2771). */
85
+ export class ClaudeHeadlessAdapter implements ProviderAdapter {
86
+ readonly statusSourceId: string;
87
+ readonly permissionPromptHandler = claudePermissionPromptHandler;
88
+ readonly toolHookPaths = claudeToolHookPaths;
89
+ readonly compactSupportsInstructions = true;
90
+ readonly reportsContextResets = true;
91
+ // Stream-json input sent mid-turn queues behind the live turn, so context commands wait for idle.
92
+ readonly awaitsIdleBeforeContextCommand = true;
93
+ // clearContext resolves only on Claude's conversation_reset boundary (clearConfirmed), so the
94
+ // continuation is delivered in the same command, before any queued inbox message.
95
+ readonly couplesClearInjectContinuation = true;
96
+ private readonly launch: ClaudeAdapter;
97
+ private status: (event: ProviderStatusUpdate) => void = () => {};
98
+ private response: (event: ProviderSessionEvent) => void = () => {};
99
+ private state?: HeadlessState;
100
+ private watching = true;
101
+ private tokenUsage?: { capture: ClaudeTranscriptTokenUsageCapture; reporter: ProviderTokenUsageReporter };
102
+ // Instance-scoped so tests can shorten the bounds.
103
+ private readonly refusalGraceMs = REFUSAL_GRACE_MS;
104
+ private readonly contextTimeouts = { compact: COMPACT_BOUNDARY_TIMEOUT_MS, clear: CLEAR_BOUNDARY_TIMEOUT_MS, recovery: CONTEXT_COMMAND_RECOVERY_MS };
105
+ readonly collectSessionEvents = collectClaudeTranscriptSessionEvents;
106
+ readonly collectSessionArchiveSegment = collectClaudeTranscriptArchiveSegment;
107
+
108
+ constructor(readonly provider: string = "claude") {
109
+ this.statusSourceId = provider;
110
+ this.launch = new ClaudeAdapter({ provider });
111
+ }
112
+
113
+ preloadRunnerConfig() { return this.launch.preloadRunnerConfig(); }
114
+ hydrateSpawnConfig(config: RunnerSpawnConfig) { return this.launch.hydrateSpawnConfig(config); }
115
+ onStatusChange(cb: (event: ProviderStatusUpdate) => void) { this.status = cb; }
116
+ onSessionEvent(cb: (event: ProviderSessionEvent) => void) { this.response = cb; }
117
+ stopStatusWatchers() { this.watching = false; }
118
+ resumeStatusWatchers() { this.watching = true; }
119
+
120
+ configureTokenUsageReporter(reporter: ProviderTokenUsageReporter): void {
121
+ this.tokenUsage = { reporter, capture: new ClaudeTranscriptTokenUsageCapture({ post: reporter.report, debug: reporter.debug }) };
122
+ }
123
+
124
+ async captureSubagentTokenUsage(transcriptPath: string): Promise<void> {
125
+ if (!this.tokenUsage) throw new Error("Token usage reporter is not configured");
126
+ await this.trackTokenUsageCapture(transcriptPath, () => this.tokenUsage!.capture.captureSubagent(transcriptPath, true));
127
+ }
128
+
129
+ async captureFinalTokenUsage(transcriptPath: string | undefined, ctx: ProviderSessionTurnContext): Promise<void> {
130
+ const path = transcriptPath ?? (this.state && this.transcriptPath(this.state));
131
+ await this.trackTokenUsageCapture(path, () => path && this.tokenUsage
132
+ ? this.tokenUsage.capture.captureWithSettle(path, { pollIntervalMs: ctx.settlePollIntervalMs, timeoutMs: ctx.settlePollTimeoutMs })
133
+ : Promise.resolve(0), false).catch((error) => this.tokenUsage?.reporter.reportFailure(error));
134
+ }
135
+
136
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
137
+ if (!config.headless) throw new Error("Claude stream-json transport requires --headless");
138
+ // Use the existing provisioning, permission policy and credential-isolation seam.
139
+ // Prompts enter through stdin only; no positional/one-shot prompt at launch.
140
+ const args = this.launch.buildSpawnArgs({ ...config, prompt: undefined, rig: undefined }, { ...providerConfig, command: "claude" });
141
+ const transportArgs: string[] = [];
142
+ for (let i = 0; i < args.args.length; i++) {
143
+ const arg = args.args[i]!;
144
+ if (arg === "--input-format" || arg === "--output-format") {
145
+ if (args.args[++i] !== "stream-json") throw new Error("Claude headless transport requires stream-json input and output");
146
+ continue;
147
+ }
148
+ if (/^--(?:input|output)-format=/.test(arg)) {
149
+ if (!arg.endsWith("=stream-json")) throw new Error("Claude headless transport requires stream-json input and output");
150
+ continue;
151
+ }
152
+ transportArgs.push(arg);
153
+ }
154
+ return { ...args, args: [...transportArgs, "--print", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--replay-user-messages"],
155
+ env: writeChildEnv({}, { ...args.env, AGENT_RELAY_CLAUDE_TRANSPORT: "stream-json" }) };
156
+ }
157
+
158
+ async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
159
+ await verifyClaudeSubagentVersion(config);
160
+ const args = this.buildSpawnArgs(config, config.providerConfig);
161
+ this.watching = true;
162
+ const child = Bun.spawn([args.command, ...args.args], {
163
+ cwd: args.cwd,
164
+ env: args.env,
165
+ stdin: "pipe", stdout: "pipe", stderr: "pipe",
166
+ });
167
+ const state: HeadlessState = { child, stopping: false, initialized: false, submitConfirmed: false, lastActivityAt: Date.now(), controlRequests: new Map(),
168
+ configHome: claudeConfigHome(args.env.CLAUDE_CONFIG_DIR), mirror: new ClaudeHeadlessMirror(), abandonedCommands: new Set() };
169
+ this.state = state;
170
+ // Drain stderr continuously: a full pipe must never wedge the provider.
171
+ void this.readLines(child.stderr, (line) => console.error(`[${this.provider} headless] ${line}`)).catch(() => {});
172
+ const stdout = this.readLines(child.stdout, (line) => {
173
+ if (state !== this.state || state.stopping) return;
174
+ let event: Record<string, unknown>;
175
+ try { event = JSON.parse(line); } catch {
176
+ console.error(`[${this.provider} headless] skipping non-JSON stdout line`);
177
+ return;
178
+ }
179
+ if (!event || typeof event !== "object" || typeof event.type !== "string") {
180
+ console.error(`[${this.provider} headless] skipping non-event stdout JSON`);
181
+ return;
182
+ }
183
+ state.lastActivityAt = Date.now();
184
+ if (event.type === "control_response") return this.onControlResponse(state, event);
185
+ if (state.contextCommand && !state.contextCommand.timedOut) this.observeContextCommand(state, state.contextCommand, event);
186
+ else this.observeUnwaitedReset(state, event);
187
+ if (state.contextCommand?.timedOut) this.observeContextCommand(state, state.contextCommand, event);
188
+ if (event.type === "system" && event.subtype === "init" && !state.initialized) {
189
+ state.initialized = true;
190
+ state.receipt?.ready();
191
+ }
192
+ if (event.type === "system" && event.subtype === "init" && typeof event.session_id === "string") {
193
+ state.sessionId = event.session_id;
194
+ if (typeof event.cwd === "string") state.cwd = event.cwd;
195
+ // The runner learns the transcript path from busy/idle edges; a first turn that fails has no later one.
196
+ if (state.turnId || state.contextCommand) this.emitStatus({ status: "busy", reason: "provider-turn", ...(state.turnId ? { id: state.turnId } : {}), ...this.transcriptStatus(state) });
197
+ }
198
+ // Claude replays the input once the API answers, so a throttled request keeps its replay behind every internal retry.
199
+ if (event.type === "system" && event.subtype === "api_retry" && state.turnId && !state.contextCommand) {
200
+ state.submitConfirmed = true;
201
+ state.unknown = undefined;
202
+ state.receipt?.retrying(typeof event.retry_delay_ms === "number" ? event.retry_delay_ms : 0);
203
+ }
204
+ // Native start confirms consumption before API prefill/thinking can delay the user replay.
205
+ if ((event.type === "command_lifecycle" && event.state === "started" && event.command_uuid === state.turnId)
206
+ || (event.type === "user" && event.uuid === state.turnId)) {
207
+ state.submitConfirmed = true;
208
+ state.unknown = undefined;
209
+ state.receipt?.resolve();
210
+ }
211
+ if (event.type === "assistant" && !event.parent_tool_use_id) {
212
+ const apiError = claudeApiErrorOf(event);
213
+ if (apiError) state.apiError = apiError;
214
+ else state.refusalWatch?.settle(false);
215
+ }
216
+ if (event.type === "result" && typeof event.user_message_uuid === "string" && state.abandonedCommands.delete(event.user_message_uuid)) return;
217
+ if (this.watching) for (const step of state.mirror.observe(event, state.turnId)) this.response(step);
218
+ if (event.type === "result") { if (state.turnId) this.captureTokenUsage(state); this.onNativeResult(event); }
219
+ const nativeHold = event.type === "result" ? this.nativeResultHold(event) : undefined;
220
+ if (event.type !== "result" || !state.turnId) { if (nativeHold) this.emitStatus({ status: "idle", providerState: nativeHold }); return; }
221
+ const turnId = state.turnId;
222
+ const replyRoute = state.replyRoute;
223
+ state.replyRoute = undefined;
224
+ state.submitConfirmed = true;
225
+ state.unknown = undefined;
226
+ state.turnId = undefined;
227
+ const apiError = state.apiError;
228
+ state.apiError = undefined;
229
+ if (event.is_error !== false || event.subtype !== "success") {
230
+ if (state.interruptedTurnId === turnId) {
231
+ // Our own interrupt ended the turn (error_during_execution); it ends idle, not failed.
232
+ state.interruptedTurnId = undefined;
233
+ state.refusalWatch?.settle(false);
234
+ this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, ...this.transcriptStatus(state) });
235
+ return;
236
+ }
237
+ // A throttle, quota wall or overload refusal is neither delivered nor failed: hold, then resume (#2619).
238
+ const turnError = claudeTurnError(event, apiError);
239
+ const hold = nativeHold ?? (turnError && claudeRefusalHold(turnError, this.statusSourceId));
240
+ if (hold) {
241
+ state.refusedTurnId = turnId;
242
+ state.refusalWatch?.settle(true);
243
+ // Input acknowledged before the refusal landed was never answered: the runner parks it again to replay after the hold.
244
+ const refusedInputIds = state.acked?.turnId === turnId ? state.acked.ids : undefined;
245
+ this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, providerState: hold, ...(refusedInputIds ? { refusedInputIds } : {}), ...this.transcriptStatus(state) });
246
+ return;
247
+ }
248
+ state.refusalWatch?.settle(false);
249
+ // Do not turn an error result into a successful report-up.
250
+ this.emitStatus({ status: "error", clear: ["provider-turn"], id: turnId, ...this.transcriptStatus(state), metadata: claudeResultFailure(event) });
251
+ return;
252
+ }
253
+ state.refusalWatch?.settle(false);
254
+ // Stopped watchers intentionally drop finals during teardown; resume cannot replay them.
255
+ if (this.watching && typeof event.result === "string" && event.result.trim()) {
256
+ this.response({ type: "response", body: event.result, origin: "provider", turnId, final: true,
257
+ confidence: "definitive", endedBy: "completed", replyToUserObligation: true, replyRoute });
258
+ }
259
+ this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, ...this.transcriptStatus(state) });
260
+ }).catch((error) => {
261
+ if (state !== this.state || state.stopping) return;
262
+ console.error(`[${this.provider} headless] ${error}`);
263
+ this.abandonPending(state, "Claude headless stream failed", error);
264
+ this.emitStatus("error");
265
+ child.kill("SIGKILL");
266
+ });
267
+ const nativeExit = child.exited.then(async (code) => {
268
+ // The final result can still be buffered when the process exits (#3314).
269
+ await stdout;
270
+ await this.onNativeExit(child, code, state.stopping);
271
+ this.abandonPending(state, "Claude exited");
272
+ if (state === this.state && !state.stopping) this.emitStatus(code === 0 ? "offline" : "error");
273
+ }).catch((error) => {
274
+ if (state !== this.state || state.stopping) return;
275
+ console.error(`[${this.provider} headless] exit observation failed: ${error}`);
276
+ this.abandonPending(state, "Claude exit observation failed", error);
277
+ this.emitStatus("error");
278
+ });
279
+ return { pid: child.pid, process: child, meta: { headlessState: state, nativeExit, env: args.env, transport: "stream-json", getContext: () => state.mirror.context(),
280
+ ...claudeEffortSwitchMeta(this.provider, config, false) } };
281
+ }
282
+
283
+ async setEffort(process: ManagedProcess, effort: string): Promise<{ effort: string }> {
284
+ const state = this.getState(process);
285
+ await this.controlRequest(state, { subtype: "apply_flag_settings", settings: { effortLevel: effort } });
286
+ const { receipt } = await this.controlRequest(state, { subtype: "get_settings" });
287
+ const applied = isRecord(receipt) && isRecord(receipt.applied) ? receipt.applied.effort : undefined;
288
+ if (applied !== effort) throw new Error(`Claude headless effort verification failed: requested ${effort}, applied ${String(applied)}`);
289
+ return { effort };
290
+ }
291
+
292
+ async deliver(process: ManagedProcess, messages: Message[], ctx?: ProviderDeliverContext): Promise<number[] | DeliverResult> {
293
+ const state = this.getState(process);
294
+ if (state.turnId || state.contextCommand || ctx?.isRecipientMidTurn?.()) return [];
295
+ if (!messages.length) return [];
296
+ state.replyRoute = deliveredBatchReplyRoute(messages);
297
+ let outcome: void | "held";
298
+ try { outcome = await this.deliverInitialPrompt(process, claudeProviderMessageText(messages)); }
299
+ catch (error) { state.replyRoute = undefined; throw error; }
300
+ const turnId = state.submittedTurnId;
301
+ const ids = messages.map((message) => message.id);
302
+ // Judged synchronously on resuming: native events can have been read since the submit settled, however stdout was batched.
303
+ // Refused under a provider hold: not acknowledged, parked by the runner until a turn goes through again.
304
+ if (outcome === "held" || (turnId && state.refusedTurnId === turnId)) return { acknowledgedIds: [], providerHeldIds: ids };
305
+ if (turnId && state.turnId === turnId) state.acked = { turnId, ids };
306
+ return ids;
307
+ }
308
+
309
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: PromptOfferOptions): Promise<void | "held"> {
310
+ const state = this.getState(process);
311
+ if (state.unknown) throw new ProviderSubmitFailure("Claude headless input is still unconfirmed", false);
312
+ if (state.turnId) throw new Error("Claude headless turn already in progress");
313
+ if (state.contextCommand) throw new Error(`Claude headless ${state.contextCommand.kind} is in progress`);
314
+ const turnId = crypto.randomUUID();
315
+ state.turnId = state.submittedTurnId = turnId;
316
+ state.apiError = undefined;
317
+ state.submitConfirmed = false;
318
+ this.emitStatus({ status: "busy", reason: "provider-turn", id: turnId, pids: [state.child.pid], ...this.transcriptStatus(state) });
319
+ let timer: ReturnType<typeof setTimeout> | undefined;
320
+ let timeoutMs = state.initialized ? options?.readyTimeoutMs ?? 30_000 : Math.max(options?.readyTimeoutMs ?? 0, 120_000);
321
+ const receipt = new Promise<void>((resolve, reject) => {
322
+ const arm = (extraMs = 0) => {
323
+ if (timer) clearTimeout(timer);
324
+ timer = setTimeout(() => reject(new ProviderSubmitFailure("Claude headless input receipt timed out", false)), timeoutMs + extraMs);
325
+ };
326
+ state.receipt = { resolve, reject, ready: () => { timeoutMs = options?.readyTimeoutMs ?? 30_000; arm(); }, retrying: (delayMs) => arm(delayMs) };
327
+ arm();
328
+ });
329
+ // Attach the rejection handler before flushing stdin (exit/read errors may race it).
330
+ void receipt.catch(() => {});
331
+ try {
332
+ await this.writeUserInput(state, turnId, prompt);
333
+ await receipt;
334
+ } catch (error) {
335
+ state.replyRoute = undefined;
336
+ // Ambiguous pipe failure: never silently retry an assignment that may have started.
337
+ if (!state.submitConfirmed) state.unknown = { since: Date.now(), timeoutMs };
338
+ this.emitStatus("error");
339
+ throw new ProviderSubmitFailure("Claude headless stdin submission could not be confirmed", false, error);
340
+ } finally { if (timer) clearTimeout(timer); state.receipt = undefined; }
341
+ if (await this.refusedWithinGrace(state, turnId) || state.refusedTurnId === turnId) return "held";
342
+ }
343
+
344
+ /** Whether the turn just submitted was refused under a provider hold, judged only on its first outcome. */
345
+ private refusedWithinGrace(state: HeadlessState, turnId: string): Promise<boolean> {
346
+ if (state.refusedTurnId === turnId) return Promise.resolve(true);
347
+ if (state.turnId !== turnId) return Promise.resolve(false);
348
+ return new Promise((resolve) => {
349
+ const settle = (refused: boolean) => {
350
+ clearTimeout(timer);
351
+ if (state.refusalWatch?.turnId === turnId) state.refusalWatch = undefined;
352
+ resolve(refused);
353
+ };
354
+ const timer = setTimeout(() => settle(false), this.refusalGraceMs);
355
+ state.refusalWatch = { turnId, settle };
356
+ });
357
+ }
358
+
359
+ async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
360
+ const state = process.meta?.headlessState as HeadlessState | undefined;
361
+ if (!state || state !== this.state || state.stopping || state.child.exitCode !== null) return "unknown";
362
+ this.expireUnknownSubmit(state);
363
+ if (state.unknown) return "unknown";
364
+ return state.turnId || state.contextCommand ? "busy" : "idle";
365
+ }
366
+
367
+ async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
368
+ const command = opts?.instructions?.trim() ? `/compact ${opts.instructions.trim()}` : "/compact";
369
+ const done = await this.runContextCommand(process, "compact", command, this.contextTimeouts.compact);
370
+ const state = process.meta?.headlessState as HeadlessState | undefined;
371
+ state?.mirror.resetContext(typeof done.boundary?.post_tokens === "number" ? done.boundary.post_tokens : undefined);
372
+ this.emitStatus({ status: "idle", reason: "provider-turn", timeline: { status: "compacted", timestamp: Date.now() }, metadata: { compactTrigger: "manual" }, ...this.transcriptStatus(state) });
373
+ return { method: "stream-json", command: "/compact", boundary: done.boundary };
374
+ }
375
+
376
+ async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
377
+ const previousSessionId = (process.meta?.headlessState as HeadlessState | undefined)?.sessionId;
378
+ const done = await this.runContextCommand(process, "clear", "/clear", this.contextTimeouts.clear);
379
+ const state = process.meta?.headlessState as HeadlessState | undefined;
380
+ state?.mirror.clearContext();
381
+ this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["subagent"],
382
+ timeline: { status: "context-cleared", timestamp: Date.now() }, ...this.transcriptStatus(state) });
383
+ return { method: "stream-json", command: "/clear", clearConfirmed: true, previousSessionId, sessionId: done.sessionId };
384
+ }
385
+
386
+ async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
387
+ const state = this.getState(process);
388
+ const turnId = state.turnId;
389
+ if (!turnId) throw new Error("no active Claude headless turn to interrupt");
390
+ state.interruptedTurnId = turnId;
391
+ try {
392
+ return { method: "stream-json-interrupt", turnId, ...await this.controlRequest(state, { subtype: "interrupt" }) };
393
+ } catch (error) {
394
+ if (state.interruptedTurnId === turnId) state.interruptedTurnId = undefined;
395
+ throw error;
396
+ }
397
+ }
398
+
399
+ async confirmSubmitRecovery(process: ManagedProcess): Promise<boolean> {
400
+ const state = this.getState(process);
401
+ return state.submitConfirmed;
402
+ }
403
+
404
+ releaseSubmitHold(process: ManagedProcess): void {
405
+ const state = this.getState(process);
406
+ state.replyRoute = undefined;
407
+ if (state.unknown) { state.unknown = undefined; state.turnId = undefined; }
408
+ }
409
+
410
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<ShutdownOutcome> {
411
+ const state = process.meta?.headlessState as HeadlessState | undefined;
412
+ if (!state) return "killed";
413
+ state.stopping = true;
414
+ this.abandonPending(state, "Claude headless transport stopped");
415
+ return terminateProviderProcesses([state.child], opts, () => state.child.stdin.end());
416
+ }
417
+
418
+ private transcriptPath(state: HeadlessState): string | undefined {
419
+ return state.sessionId && state.cwd ? claudeTranscriptPath(state.configHome, state.cwd, state.sessionId) : undefined;
420
+ }
421
+
422
+ /** The runner keys transcript archive, insights and terminal token settling off this path. */
423
+ private transcriptStatus(state: HeadlessState | undefined): { transcriptPath?: string } {
424
+ const transcriptPath = state && this.transcriptPath(state);
425
+ return transcriptPath ? { transcriptPath } : {};
426
+ }
427
+
428
+ private captureTokenUsage(state: HeadlessState): void {
429
+ const path = this.transcriptPath(state);
430
+ this.trackTokenUsageCapture(path, () => path && this.tokenUsage ? this.tokenUsage.capture.capture(path) : Promise.resolve(0))
431
+ .catch((error) => this.tokenUsage?.reporter.reportFailure(error));
432
+ }
433
+
434
+ private async trackTokenUsageCapture(path: string | undefined, read: () => Promise<number>, pending = true): Promise<void> {
435
+ const reporter = this.tokenUsage?.reporter;
436
+ if (!reporter) return;
437
+ if (reporter.capture) await reporter.capture(`claude-transcript:${path ?? "unresolved"}`, read, pending);
438
+ else await read();
439
+ }
440
+
441
+ private getState(process: ManagedProcess): HeadlessState {
442
+ const state = process.meta?.headlessState as HeadlessState | undefined;
443
+ if (!state || state !== this.state || state.stopping || state.child.exitCode !== null) throw new Error("Claude headless process is unavailable");
444
+ this.expireUnknownSubmit(state);
445
+ return state;
446
+ }
447
+
448
+ private expireUnknownSubmit(state: HeadlessState): void {
449
+ if (!state.unknown || Date.now() - Math.max(state.unknown.since, state.lastActivityAt) < 2 * state.unknown.timeoutMs) return;
450
+ const turnId = state.turnId;
451
+ state.unknown = undefined;
452
+ state.turnId = undefined;
453
+ state.replyRoute = undefined;
454
+ // Silence releases activity only: the runner's non-retry-safe delivery hold remains.
455
+ console.error(`[${this.provider} headless] unconfirmed input expired without activity; delivery remains held`);
456
+ this.emitStatus({ status: "idle", reason: "provider-turn", clear: ["provider-turn"], id: turnId, metadata: { submitDisposition: "held" } });
457
+ }
458
+
459
+ /** Submit a slash command and resolve only on its native boundary. A timeout fails the command and starts {@link recoverContextCommand}. */
460
+ private async runContextCommand(process: ManagedProcess, kind: ContextCommand["kind"], text: string, timeoutMs: number): Promise<ContextCommand> {
461
+ const state = this.getState(process);
462
+ if (state.turnId || state.unknown || state.contextCommand) throw new Error(`Claude headless ${kind} refused: provider input is not idle`);
463
+ let resolve!: (command: ContextCommand) => void, reject!: (error: Error) => void, settled = false;
464
+ const done = new Promise<ContextCommand>((res, rej) => { resolve = res; reject = rej; });
465
+ void done.catch(() => {});
466
+ const command: ContextCommand = { kind, uuid: crypto.randomUUID(), settle: (error) => {
467
+ if (settled) return;
468
+ settled = true;
469
+ clearTimeout(timer);
470
+ if (error) reject(error); else resolve(command);
471
+ } };
472
+ const timer = setTimeout(() => {
473
+ command.timedOut = true;
474
+ command.settle(new Error(`Claude headless ${kind} boundary timed out after ${timeoutMs}ms`));
475
+ this.recoverContextCommand(state, command);
476
+ }, timeoutMs);
477
+ state.contextCommand = command;
478
+ state.apiError = undefined;
479
+ this.emitStatus({ status: "busy", reason: "provider-turn", timeline: { status: kind === "compact" ? "compacting" : "clearing-context", timestamp: Date.now() } });
480
+ try {
481
+ await this.writeUserInput(state, command.uuid, text);
482
+ return await done;
483
+ } catch (error) {
484
+ if (state.contextCommand === command && !command.timedOut) state.contextCommand = undefined;
485
+ // A dying child reports its own terminal status; only a live, unfenced provider returns to idle.
486
+ if (state === this.state && !state.stopping && state.child.exitCode === null && !state.contextCommand) {
487
+ this.emitStatus({ status: "idle", reason: "provider-turn", ...(command.hold ? { providerState: command.hold } : {}) });
488
+ }
489
+ throw error;
490
+ }
491
+ }
492
+
493
+ /** Interrupt the stuck command; if Claude still never settles it, release the input fence with a visible error. */
494
+ private recoverContextCommand(state: HeadlessState, command: ContextCommand): void {
495
+ if (state.contextCommand !== command) return;
496
+ void this.controlRequest(state, { subtype: "interrupt" }).catch(() => {});
497
+ setTimeout(() => {
498
+ if (state.contextCommand !== command || state !== this.state || state.stopping || state.child.exitCode !== null) return;
499
+ state.contextCommand = undefined;
500
+ state.abandonedCommands.add(command.uuid);
501
+ const body = `Claude headless /${command.kind} did not settle within ${this.contextTimeouts.recovery}ms of its timeout; input released.`;
502
+ console.error(`[${this.provider} headless] ${body}`);
503
+ if (this.watching) this.response({ type: "narration", origin: "provider", diagnostic: true, body });
504
+ this.emitStatus({ status: "idle", reason: "provider-turn" });
505
+ }, this.contextTimeouts.recovery);
506
+ }
507
+
508
+ /** #3398 — a reset no live command waits for (a timed-out or abandoned one, or Claude's own) is still a reset. */
509
+ private observeUnwaitedReset(state: HeadlessState, event: Record<string, unknown>): void {
510
+ const compacted = event.type === "system" && event.subtype === "compact_boundary";
511
+ if (!compacted && event.type !== "conversation_reset") return;
512
+ if (compacted) state.mirror.resetContext(isRecord(event.compact_metadata) && typeof event.compact_metadata.post_tokens === "number" ? event.compact_metadata.post_tokens : undefined);
513
+ else state.mirror.clearContext();
514
+ const timeline = { status: compacted ? "compacted" : "context-cleared", timestamp: Date.now() };
515
+ const trigger = compacted && isRecord(event.compact_metadata) && typeof event.compact_metadata.trigger === "string" ? event.compact_metadata.trigger : undefined;
516
+ const busy = state.turnId || state.contextCommand;
517
+ this.emitStatus({ status: busy ? "busy" : "idle", reason: "provider-turn", ...(state.turnId ? { id: state.turnId } : {}), timeline,
518
+ ...(trigger ? { metadata: { compactTrigger: trigger } } : {}), ...this.transcriptStatus(state) });
519
+ }
520
+
521
+ private observeContextCommand(state: HeadlessState, command: ContextCommand, event: Record<string, unknown>): void {
522
+ if (command.kind === "compact" && event.type === "system") {
523
+ if (event.subtype === "status" && event.compact_result === "failed") command.failure = String(event.compact_error ?? "compaction failed");
524
+ if (event.subtype === "compact_boundary") command.boundary = isRecord(event.compact_metadata) ? event.compact_metadata : {};
525
+ }
526
+ if (command.kind === "clear" && event.type === "conversation_reset" && event.user_message_uuid === command.uuid) {
527
+ command.boundary = { conversationId: event.new_conversation_id };
528
+ }
529
+ if (command.kind === "clear" && command.boundary && event.type === "system" && event.subtype === "init" && typeof event.session_id === "string") {
530
+ command.sessionId = event.session_id;
531
+ }
532
+ if (event.type !== "result" || (event.user_message_uuid !== undefined && event.user_message_uuid !== command.uuid)) return;
533
+ state.contextCommand = undefined;
534
+ const apiError = state.apiError;
535
+ state.apiError = undefined;
536
+ if (event.is_error === true) {
537
+ const turnError = claudeTurnError(event, apiError);
538
+ command.hold = turnError && claudeRefusalHold(turnError, this.statusSourceId);
539
+ }
540
+ if (command.timedOut) {
541
+ // The caller already failed; Claude has now settled the command, so release the input fence.
542
+ this.emitStatus({ status: "idle", reason: "provider-turn" });
543
+ return;
544
+ }
545
+ if (command.boundary && !command.failure && event.is_error !== true) command.settle();
546
+ else command.settle(new Error(`Claude headless ${command.kind} completed without a ${command.kind === "compact" ? "compact_boundary" : "conversation_reset"}: ${command.failure ?? String(event.subtype ?? "no boundary")}`));
547
+ }
548
+
549
+ private controlRequest(state: HeadlessState, request: Record<string, unknown>): Promise<Record<string, unknown>> {
550
+ const requestId = `relay-${crypto.randomUUID()}`;
551
+ return new Promise((resolve, reject) => {
552
+ const timer = setTimeout(() => settle(new Error(`Claude headless ${String(request.subtype)} receipt timed out`)), CONTROL_RECEIPT_TIMEOUT_MS);
553
+ const settle = (error?: Error, response?: Record<string, unknown>) => {
554
+ clearTimeout(timer);
555
+ state.controlRequests.delete(requestId);
556
+ if (error) reject(error); else resolve({ requestId, receipt: response ?? {} });
557
+ };
558
+ state.controlRequests.set(requestId, settle);
559
+ const failed = (error: unknown) => settle(new Error(`Claude headless control request write failed: ${error}`));
560
+ try {
561
+ state.child.stdin.write(JSON.stringify({ type: "control_request", request_id: requestId, request }) + "\n");
562
+ Promise.resolve(state.child.stdin.flush()).catch(failed);
563
+ } catch (error) { failed(error); }
564
+ });
565
+ }
566
+
567
+ private onControlResponse(state: HeadlessState, event: Record<string, unknown>): void {
568
+ const response = isRecord(event.response) ? event.response : undefined;
569
+ const settle = typeof response?.request_id === "string" ? state.controlRequests.get(response.request_id) : undefined;
570
+ if (!response || !settle) return;
571
+ if (response.subtype === "success") settle(undefined, isRecord(response.response) ? response.response : {});
572
+ else settle(new Error(`Claude headless control request failed: ${String(response.error ?? response.subtype)}`));
573
+ }
574
+
575
+ private abandonPending(state: HeadlessState, cause: string, error?: unknown): void {
576
+ state.receipt?.reject(new ProviderSubmitFailure(`${cause} before input receipt`, false, error));
577
+ state.refusalWatch?.settle(false);
578
+ for (const settle of [...state.controlRequests.values()]) settle(new Error(`${cause} before the control receipt`));
579
+ state.contextCommand?.settle(new Error(`${cause} before the ${state.contextCommand.kind} boundary`));
580
+ }
581
+
582
+ private async writeUserInput(state: HeadlessState, uuid: string, content: string): Promise<void> {
583
+ state.child.stdin.write(JSON.stringify({ type: "user", uuid, session_id: "", parent_tool_use_id: null, message: { role: "user", content } }) + "\n");
584
+ await state.child.stdin.flush();
585
+ }
586
+
587
+ protected onNativeResult(_event: Record<string, unknown>): void {}
588
+ protected nativeResultHold(_event: Record<string, unknown>): ProviderState | undefined { return undefined; }
589
+ protected nativeSessionId(process: ManagedProcess): string | undefined { return (process.meta?.headlessState as HeadlessState | undefined)?.sessionId; }
590
+ protected retireAtBoundary(process: ManagedProcess): void { (process.meta!.headlessState as HeadlessState).stopping = true; }
591
+
592
+ /** Replace an idle native process while keeping the runner's handle and reply routing. */
593
+ protected async replaceAtBoundary(process: ManagedProcess, config: RunnerSpawnConfig): Promise<void> {
594
+ const state = process.meta?.headlessState as HeadlessState;
595
+ if (state.turnId || state.contextCommand || state.unknown) throw new Error("Claude rollover requires a confirmed turn boundary");
596
+ if (await ClaudeHeadlessAdapter.prototype.shutdown.call(this, process, { graceful: true, timeoutMs: 1_000 }) === "failed-to-kill") throw new Error("Claude rollover could not stop the previous native process");
597
+ const next = await ClaudeHeadlessAdapter.prototype.spawn.call(this, config);
598
+ const nextState = next.meta!.headlessState as HeadlessState;
599
+ nextState.replyRoute = state.replyRoute;
600
+ nextState.mirror = state.mirror;
601
+ Object.assign(process, next);
602
+ }
603
+ protected async onNativeExit(_child: Bun.Subprocess, _code: number, _retired: boolean): Promise<void> {}
604
+
605
+ protected emitStatus(event: ProviderStatusUpdate) { if (this.watching) this.status(event); }
606
+ private async readLines(stream: ReadableStream<Uint8Array>, receive: (line: string) => void) {
607
+ const input = Readable.fromWeb(stream as unknown as Parameters<typeof Readable.fromWeb>[0]);
608
+ const lines = createInterface({ input });
609
+ try {
610
+ for await (const line of lines) if (line.trim()) {
611
+ // Consumer failures are not pipe failures and must not kill a healthy child.
612
+ try { receive(line); } catch (error) { console.error(`[${this.provider} headless] event handler failed: ${error}`); }
613
+ }
614
+ }
615
+ finally { lines.close(); }
616
+ }
617
+ }