@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,730 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+ import type { InteractivePrompt, Message, OperatorDecisionFileInput, ReplyObligation } from "@humwick/sdk";
3
+ import { errMessage, isRecord } from "@humwick/sdk";
4
+ import type { ProviderPermissionDecisionInput, ProviderPermissionDecisionReason, ProviderPermissionPromptHandler, ProviderPermissionResolvedPrompt, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
+ import { obligationNagEligible } from "./reply-obligation-cache";
6
+ import { relayReplyActionText } from "./relay-instructions";
7
+ import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
8
+ import { buildRateLimitProviderState } from "./rate-limit";
9
+ import { bestEffortDeletePendingPermissionRequest, bestEffortListPendingPermissionRequests, bestEffortUpsertPendingPermissionRequest, type PendingPermissionRequestRecord, type PendingPermissionStore } from "./pending-permission-store";
10
+
11
+ // Provider permission hooks can wait a long time. Resolve the server-side wait with
12
+ // a margin under the provider hook timeout so the server returns a deterministic
13
+ // decision before the provider abandons the hook.
14
+ const PERMISSION_WAIT_MS = 880_000;
15
+ // Distinct from a human dismissal ("Dismissed/Denied from Humwick dashboard") so the
16
+ // agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
17
+ function permissionTimeoutReason(view?: Pick<InteractivePrompt, "toolName" | "title">): string {
18
+ const tool = view?.toolName?.trim() || view?.title?.replace(/^Approve\s+/i, "").trim() || "provider tool";
19
+ return `Permission request for ${tool} timed out after ~15 minutes without a coordinator decision; the tool was denied (timeout, not a dismissal). Re-ask if you still need it.`;
20
+ }
21
+ const PERMISSION_STOP_REASON = "The runner restarted while this permission request was pending; the original hook is no longer connected.";
22
+ const RESOLVED_APPROVAL_LIMIT = 1024;
23
+ // #1173: the immediate PreToolUse response for a "questions" prompt (see the deny-then-hold
24
+ // branch in handlePermissionRequest below). Bridges the hand-off so the model doesn't read a
25
+ // bare denial and re-ask — the real answer follows shortly as an injected user prompt.
26
+ const PENDING_QUESTION_DENY_REASON =
27
+ "The user is answering this question directly via Agent Relay, not through this tool call — do not re-ask it. Their answer will follow as your next message; wait for it and continue from there.";
28
+
29
+ // A hook that failed in a way it could not handle itself reports here so the
30
+ // failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
31
+ // log; Phase 2 (#196) will additionally route it through the runner outbox to the
32
+ // server.
33
+ export interface HookFatalReport {
34
+ hook: string;
35
+ error: string;
36
+ }
37
+
38
+ interface MonitorSocketData {
39
+ kind: "monitor";
40
+ }
41
+
42
+ type MonitorSocket = ServerWebSocket<MonitorSocketData>;
43
+
44
+ interface PendingPermissionWaiter {
45
+ resolve(input: ProviderPermissionDecisionInput): void;
46
+ timer: Timer;
47
+ suppressResolvedStatus?: boolean;
48
+ }
49
+
50
+ export interface ControlServer {
51
+ port: number;
52
+ url: string;
53
+ stop(): void;
54
+ deliverToMonitor(messages: Message[], timeoutMs?: number): Promise<number[]>;
55
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean;
56
+ }
57
+
58
+ export interface ControlServerOptions {
59
+ onStatus(status: ProviderStatusEvent): void;
60
+ onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
61
+ // #2459 — the CURRENT non-secret attachment generation (derived from the provider's active
62
+ // session id), read by the orchestrator before terminal input and on stream connect/
63
+ // reconnect to detect a session rotation. Returns ONLY that digest — never the session id,
64
+ // never a credential. Absent handler ⇒ endpoint reports no generation, which the
65
+ // orchestrator treats as "nothing to check" (e.g. codex's attach target cannot go stale).
66
+ onTerminalGeneration?(): Promise<string | undefined>;
67
+ onReplyObligations?(): Promise<ReplyObligation[]>;
68
+ // Provider session-final and pre-flush hooks hand over their capture source so
69
+ // the runner can surface the provider turn in dashboard chat without the agent
70
+ // re-emitting it via /reply.
71
+ onSubagentUsage?(transcriptPath: string): Promise<void>;
72
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; promptId?: string; isPreFlush?: boolean; toolName?: string; toolInput?: unknown }): Promise<{ reasoningSettled: boolean; toolUseOccurredAt?: number } | void>;
73
+ onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
74
+ onPermissionAbandoned?(input: PendingPermissionRequestRecord & { reason: string; decisionReason: ProviderPermissionDecisionReason }): Promise<void> | void;
75
+ // #1173: a "questions" (AskUserQuestion) prompt's own PreToolUse hook is answered with `deny`
76
+ // immediately (see the branch in handlePermissionRequest) instead of held open for the human,
77
+ // so there is no live HTTP request left to answer once the decision resolves. This delivers
78
+ // that resolution instead: `text` is the prompt to inject as a normal user turn.
79
+ onAnswerInject?(input: { approvalId: string; text?: string }): Promise<void> | void;
80
+ // #2914: files a "questions" prompt as a durable operator decision; undefined = not filed, so the
81
+ // question falls back to the live deny-then-hold relay above.
82
+ onQuestionDecision?(input: OperatorDecisionFileInput): Promise<{ id: number } | undefined>;
83
+ // A provider user-prompt hook hands over text typed directly into the session
84
+ // so the runner can mirror it into dashboard chat.
85
+ onUserPrompt?(input: { prompt: string; transcriptPath?: string; promptId?: string; firedAt?: number }): Promise<void>;
86
+ // A session-start hook checks in with its fire time, proving the hook path works (#2664).
87
+ onHookCheck?(input: { firedAt?: number }): void;
88
+ onToolUse?(input: unknown, signal: AbortSignal): Promise<string>;
89
+ // A provider session-boundary hook signals an imminent context reset or
90
+ // termination so the runner can run end-of-session work before the operation.
91
+ onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
92
+ onSessionStart?(input: { source?: string; providerSessionId?: string }): string | undefined;
93
+ // Phase 1 observability (#198): a hook reporting an unhandled failure. The
94
+ // control server already logs it FATAL; this is the seam for Phase 2 to also
95
+ // surface it to the server via the runner outbox.
96
+ onHookFatal?(report: HookFatalReport): void;
97
+ // Test seam: how long the permission hook waits for a dashboard decision before
98
+ // resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
99
+ permissionWaitMs?: number;
100
+ instanceId?: string;
101
+ pendingPermissionStore?: PendingPermissionStore;
102
+ permissionPromptHandler?: ProviderPermissionPromptHandler;
103
+ // #3648 — the agent runs in `open` approval mode: answer every permission request at once.
104
+ autoApprovePermissions?: boolean;
105
+ providerSourceId?: string;
106
+ stopObligationPath?: string;
107
+ monitorUnavailableMessage?: string;
108
+ }
109
+
110
+ export type ResolvedPermissionPrompt = ProviderPermissionResolvedPrompt;
111
+
112
+ export function startControlServer(options: ControlServerOptions): ControlServer {
113
+ const monitors = new Set<MonitorSocket>();
114
+ const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
115
+ const pendingPermissionRequests = new Map<string, PendingPermissionWaiter>();
116
+ // approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
117
+ // reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
118
+ // is an idempotent no-op instead of a silent command failure (#693).
119
+ const resolvedApprovals = new Set<string>();
120
+ let server!: Server<MonitorSocketData>;
121
+ recoverStoredPermissionRequests(options);
122
+
123
+ server = Bun.serve<MonitorSocketData>({
124
+ hostname: "127.0.0.1",
125
+ port: 0,
126
+ fetch(req, srv) {
127
+ const url = new URL(req.url);
128
+ if (url.pathname === "/health") return Response.json({ ok: true });
129
+ if (url.pathname === "/status" && req.method === "GET") return Response.json({ ok: true });
130
+ if (url.pathname === "/status" && req.method === "POST") {
131
+ return handleStatus(req, options);
132
+ }
133
+ if (url.pathname === "/terminal/attach-spec" && req.method === "GET") {
134
+ if (!options.onTerminalAttachSpec) return Response.json({ error: "terminal attach is unavailable" }, { status: 404 });
135
+ return options.onTerminalAttachSpec()
136
+ // #2459 round 1 (review A) — strip any credential-bearing env record from the body:
137
+ // credentials travel ONLY by runner-staged env file (spec.envFile), so nothing
138
+ // secret ever serializes into this HTTP response.
139
+ .then((spec) => {
140
+ const safeSpec: Record<string, unknown> = { ...spec };
141
+ delete safeSpec.env;
142
+ return Response.json(safeSpec);
143
+ })
144
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 409 }));
145
+ }
146
+ if (url.pathname === "/terminal/generation" && req.method === "GET") {
147
+ // #2459 — deliberately returns ONLY the non-secret generation digest. No session id,
148
+ // no password, no attach detail: this endpoint exists so the orchestrator can detect
149
+ // staleness, and anything more would widen the secret surface for no benefit.
150
+ if (!options.onTerminalGeneration) return Response.json({});
151
+ return options.onTerminalGeneration()
152
+ .then((generation) => Response.json(generation ? { generation } : {}))
153
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
154
+ }
155
+ if (url.pathname === "/reply-obligations" && req.method === "GET") {
156
+ if (!options.onReplyObligations) return Response.json({ pending: false, count: 0 });
157
+ return options.onReplyObligations()
158
+ .then((obligations) => Response.json(replyObligationSummary(obligations)))
159
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
160
+ }
161
+ if (url.pathname === `/reply-obligations/${options.stopObligationPath ?? "turn-stop"}` && req.method === "GET") {
162
+ if (!options.onReplyObligations) return Response.json({});
163
+ return options.onReplyObligations()
164
+ .then((obligations) => Response.json(replyObligationStopDecision(obligations)))
165
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
166
+ }
167
+ if (url.pathname === "/permissions/request" && req.method === "POST") {
168
+ return handlePermissionRequest(req, options, pendingPermissionRequests);
169
+ }
170
+ if (url.pathname === "/subagent-usage" && req.method === "POST") {
171
+ return req.json().then(async (body) => {
172
+ if (!isRecord(body) || typeof body.agent_transcript_path !== "string" || !body.agent_transcript_path) return Response.json({ error: "agent_transcript_path required" }, { status: 400 });
173
+ if (!options.onSubagentUsage) return Response.json({ error: "sub-agent usage capture unavailable" }, { status: 503 });
174
+ await options.onSubagentUsage(body.agent_transcript_path);
175
+ return Response.json({ ok: true });
176
+ }).catch((error) => Response.json({ error: errMessage(error) }, { status: 500 }));
177
+ }
178
+ if (url.pathname === "/session-turn" && req.method === "POST") {
179
+ return handleSessionTurn(req, options);
180
+ }
181
+ if (url.pathname === "/user-prompt" && req.method === "POST") {
182
+ return handleUserPrompt(req, url, options);
183
+ }
184
+ if (url.pathname === "/hook-check" && req.method === "POST") {
185
+ options.onHookCheck?.(hookFiredAt(url));
186
+ return Response.json({ ok: Boolean(options.onHookCheck) });
187
+ }
188
+ if (url.pathname === "/tool-use" && req.method === "POST") {
189
+ return req.json().then(async (body) => {
190
+ const content = await options.onToolUse?.(body, AbortSignal.any([req.signal, AbortSignal.timeout(3_500)]));
191
+ return new Response(content ?? "");
192
+ }).catch((error) => Response.json({ error: errMessage(error) }, { status: 400 }));
193
+ }
194
+ if (url.pathname === "/session-boundary" && req.method === "POST") {
195
+ return handleSessionBoundary(req, options);
196
+ }
197
+ if (url.pathname === "/session-start" && req.method === "POST") {
198
+ return req.json().then((body) => new Response(isRecord(body) ? options.onSessionStart?.({
199
+ source: typeof body.source === "string" ? body.source : undefined,
200
+ providerSessionId: typeof body.providerSessionId === "string" ? body.providerSessionId : undefined,
201
+ }) ?? "" : "")).catch(() => new Response("", { status: 400 }));
202
+ }
203
+ if (url.pathname === "/log-level" && req.method === "GET") {
204
+ return Response.json({ level: logger.getLevel(), levels: LOG_LEVELS });
205
+ }
206
+ if (url.pathname === "/log-level" && req.method === "POST") {
207
+ return handleLogLevel(req);
208
+ }
209
+ if (url.pathname === "/hook-fatal" && req.method === "POST") {
210
+ return handleHookFatal(req, options);
211
+ }
212
+ if (url.pathname === "/rate-limit" && req.method === "POST") {
213
+ return handleRateLimit(req, options);
214
+ }
215
+ if (url.pathname === "/monitor") {
216
+ const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
217
+ return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
218
+ }
219
+ return new Response("not found", { status: 404 });
220
+ },
221
+ websocket: {
222
+ open(ws) {
223
+ monitors.add(ws);
224
+ },
225
+ message(_ws, raw) {
226
+ const parsed = parseJson(raw);
227
+ if (parsed?.type === "message.delivered" && Array.isArray(parsed.messageIds)) {
228
+ const deliveryId = typeof parsed.deliveryId === "string" ? parsed.deliveryId : "";
229
+ const pending = pendingDeliveries.get(deliveryId);
230
+ if (pending) {
231
+ clearTimeout(pending.timer);
232
+ pendingDeliveries.delete(deliveryId);
233
+ pending.resolve(parsed.messageIds.filter((id): id is number => Number.isSafeInteger(id)));
234
+ }
235
+ }
236
+ },
237
+ close(ws) {
238
+ monitors.delete(ws);
239
+ },
240
+ },
241
+ });
242
+
243
+ const port = server.port;
244
+ if (port === undefined) throw new Error("runner control server did not bind a port");
245
+
246
+ return {
247
+ port,
248
+ url: `http://127.0.0.1:${port}`,
249
+ stop() {
250
+ for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
251
+ pendingDeliveries.clear();
252
+ for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
253
+ for (const [approvalId, pending] of pendingPermissionRequests.entries()) {
254
+ const decision = withDecisionReason({
255
+ approvalId,
256
+ decision: "deny",
257
+ reason: PERMISSION_STOP_REASON,
258
+ }, "dismissed");
259
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
260
+ pending.resolve(decision);
261
+ }
262
+ pendingPermissionRequests.clear();
263
+ server.stop(true);
264
+ },
265
+ async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
266
+ if (monitors.size === 0) throw new Error(options.monitorUnavailableMessage ?? "no provider monitor connected");
267
+ const deliveryId = crypto.randomUUID();
268
+ const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
269
+ return await new Promise<number[]>((resolve, reject) => {
270
+ const timer = setTimeout(() => {
271
+ pendingDeliveries.delete(deliveryId);
272
+ reject(new Error("monitor delivery timed out"));
273
+ }, timeoutMs);
274
+ pendingDeliveries.set(deliveryId, { resolve, timer });
275
+ for (const monitor of monitors) monitor.send(payload);
276
+ });
277
+ },
278
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean {
279
+ const pending = pendingPermissionRequests.get(input.approvalId);
280
+ if (!pending) {
281
+ // Idempotent: a duplicate decision for an already-resolved approval is a
282
+ // benign no-op, not a dropped answer that fails the command (#693).
283
+ return resolvedApprovals.has(input.approvalId);
284
+ }
285
+ clearTimeout(pending.timer);
286
+ pendingPermissionRequests.delete(input.approvalId);
287
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, input.approvalId);
288
+ recordResolvedApproval(resolvedApprovals, input.approvalId);
289
+ pending.resolve(withDecisionReason(input, input.decision === "deny" || input.decision === "abort" ? "dismissed" : "answered"));
290
+ if (!pending.suppressResolvedStatus) {
291
+ options.onStatus({
292
+ status: "busy",
293
+ reason: "provider-turn",
294
+ providerState: {
295
+ state: "active",
296
+ reason: "permissionResolved",
297
+ label: options.permissionPromptHandler?.resolvedLabel ?? "Provider approval resolved",
298
+ source: options.permissionPromptHandler?.source ?? options.providerSourceId ?? "provider",
299
+ updatedAt: Date.now(),
300
+ },
301
+ });
302
+ }
303
+ return true;
304
+ },
305
+ };
306
+ }
307
+
308
+ export function recordResolvedApproval(resolvedApprovals: Set<string>, approvalId: string, limit = RESOLVED_APPROVAL_LIMIT): void {
309
+ resolvedApprovals.delete(approvalId);
310
+ resolvedApprovals.add(approvalId);
311
+ while (resolvedApprovals.size > limit) {
312
+ const oldest = resolvedApprovals.values().next().value;
313
+ if (!oldest) break;
314
+ resolvedApprovals.delete(oldest);
315
+ }
316
+ }
317
+
318
+ function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
319
+ const obligation = obligations[0];
320
+ if (!obligation) return { pending: false, count: 0 };
321
+ const subject = obligation.subject ? ` (${obligation.subject})` : "";
322
+ return {
323
+ pending: true,
324
+ count: obligations.length,
325
+ obligation,
326
+ reason: `Pending Agent Relay response for message #${obligation.messageId} from ${obligation.from}${subject}. If it still needs an answer, use ${relayReplyActionText(obligation.messageId)}. If multiple delivered messages are part of one current request, answer once to the latest relevant message. If the useful response was already delivered through Relay, do not send a separate status-only confirmation.`,
327
+ };
328
+ }
329
+
330
+ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<string, unknown> {
331
+ // #418 — never block the turn to nag for a `user` obligation: the operator already receives
332
+ // this turn via the session mirror, so an explicit /reply double-delivers. Only agent↔agent /
333
+ // spawner obligations (#413) gate the turn here; the mirror's replyTo-linking clears the user
334
+ // one. (The /reply-obligations summary endpoint is left unfiltered — the obligation IS pending
335
+ // until the mirror lands, so observability stays accurate.)
336
+ const enforceable = obligations.filter(obligationNagEligible);
337
+ const summary = replyObligationSummary(enforceable);
338
+ if (summary.pending !== true) return {};
339
+ return {
340
+ decision: "block",
341
+ reason: summary.reason,
342
+ };
343
+ }
344
+
345
+ function recoverStoredPermissionRequests(options: ControlServerOptions): void {
346
+ const store = options.pendingPermissionStore;
347
+ if (!store) return;
348
+ for (const record of bestEffortListPendingPermissionRequests(store)) {
349
+ bestEffortDeletePendingPermissionRequest(store, record.approvalId);
350
+ const decisionReason: ProviderPermissionDecisionReason = {
351
+ kind: Date.now() >= record.hookDeadlineAt ? "timeout" : "dismissed",
352
+ message: Date.now() >= record.hookDeadlineAt ? permissionTimeoutReason(record.view) : PERMISSION_STOP_REASON,
353
+ };
354
+ options.onPermissionAbandoned?.({ ...record, reason: decisionReason.message ?? PERMISSION_STOP_REASON, decisionReason });
355
+ }
356
+ }
357
+
358
+ function withDecisionReason(input: ProviderPermissionDecisionInput, kind: ProviderPermissionDecisionReason["kind"]): ProviderPermissionDecisionInput {
359
+ if (input.decisionReason) return input;
360
+ return {
361
+ ...input,
362
+ decisionReason: {
363
+ kind,
364
+ ...(input.reason ? { message: input.reason } : {}),
365
+ },
366
+ };
367
+ }
368
+
369
+ async function handlePermissionRequest(
370
+ req: Request,
371
+ options: ControlServerOptions,
372
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>,
373
+ ): Promise<Response> {
374
+ const handler = options.permissionPromptHandler;
375
+ if (!handler) return Response.json({ ok: false, reason: "permission prompt handler unavailable" }, { status: 501 });
376
+ const occurredAt = Date.now();
377
+ const body = await req.json().catch(() => null);
378
+ if (!isRecord(body)) return Response.json({});
379
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
380
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : undefined;
381
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : undefined;
382
+ const approvalId = crypto.randomUUID();
383
+
384
+ const probeView = handler.buildView(approvalId, body, true);
385
+ const answerInput = permissionRequestQuestionAnswers(body);
386
+ if (body.hook_event_name === "PermissionRequest" && probeView.kind === "questions" && answerInput) {
387
+ return Response.json(handler.buildHookResponse({ approvalId, decision: "answer", answers: answerInput }, body));
388
+ }
389
+ if (
390
+ (body.hook_event_name === "PreToolUse" || body.hook_event_name === "PermissionRequest")
391
+ && probeView.kind === "questions"
392
+ && handler.buildAnswerInjectionPrompt
393
+ ) {
394
+ const decision = await fileQuestionDecision(options, handler, body);
395
+ if (decision) return Response.json(handler.buildHookResponse({ approvalId, decision: "deny", reason: decisionFiledReason(decision.id) }, body));
396
+ return holdQuestionWithImmediateDeny({
397
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
398
+ });
399
+ }
400
+
401
+ if (options.autoApprovePermissions) return autoApprovePermission(options, handler, approvalId, body, occurredAt);
402
+ const { decision } = await awaitPermissionDecision({
403
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
404
+ blockedReason: "permissionRequest",
405
+ recommendedAction: "Review the permission request in Agent Relay.",
406
+ });
407
+ return Response.json(handler.buildHookResponse(decision, body));
408
+ }
409
+
410
+ // #3648 — bypass mode does not cover every provider check (Claude's dangerous-rm check on a variable
411
+ // target still asks). An open agent has no one to answer, so the request is approved, not held.
412
+ const OPEN_MODE_APPROVAL_REASON = "Auto-approved: the agent runs in open approval mode.";
413
+
414
+ async function autoApprovePermission(
415
+ options: ControlServerOptions,
416
+ handler: ProviderPermissionPromptHandler,
417
+ approvalId: string,
418
+ body: Record<string, unknown>,
419
+ occurredAt: number,
420
+ ): Promise<Response> {
421
+ const decision: ProviderPermissionDecisionInput = { approvalId, decision: "approve", decisionReason: { kind: "answered", message: OPEN_MODE_APPROVAL_REASON } };
422
+ if (options.onPermissionResolved) {
423
+ await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(handler.buildView(approvalId, body, true), decision, body, occurredAt))).catch(() => {});
424
+ }
425
+ return Response.json(handler.buildHookResponse(decision, body));
426
+ }
427
+
428
+ function permissionRequestQuestionAnswers(body: Record<string, unknown>): Record<string, string> | undefined {
429
+ if (body.hook_event_name !== "PermissionRequest") return undefined;
430
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
431
+ if (!isRecord(toolInput.answers)) return undefined;
432
+ const answers = Object.fromEntries(
433
+ Object.entries(toolInput.answers)
434
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1].trim().length > 0),
435
+ );
436
+ return Object.keys(answers).length ? answers : undefined;
437
+ }
438
+
439
+ async function fileQuestionDecision(options: ControlServerOptions, handler: ProviderPermissionPromptHandler, body: Record<string, unknown>): Promise<{ id: number } | undefined> {
440
+ const request = options.onQuestionDecision ? handler.buildDecisionRequest?.(body) : undefined;
441
+ return request ? options.onQuestionDecision!(request).catch(() => undefined) : undefined;
442
+ }
443
+
444
+ function decisionFiledReason(id: number): string {
445
+ return `Agent Relay filed this as operator decision #${id}; the user answers it on the Relay dashboard. Do not re-ask it and do not wait in this turn: the answer arrives later as a Relay message ("Decision #${id} answered"). Continue other work or end your turn. Next time, file questions with relay_decision directly.`;
446
+ }
447
+
448
+ function holdQuestionWithImmediateDeny(params: {
449
+ options: ControlServerOptions;
450
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
451
+ handler: ProviderPermissionPromptHandler;
452
+ approvalId: string;
453
+ body: Record<string, unknown>;
454
+ occurredAt: number;
455
+ transcriptPath: string;
456
+ toolName?: string;
457
+ toolInput?: unknown;
458
+ }): Response {
459
+ const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput } = params;
460
+ // #1173/#1194: a fresh "questions" prompt (the provider's AskUserQuestion) must not block
461
+ // its own hook response while the human answers. Denying immediately makes the provider persist
462
+ // the buffered turn plus rejected tool_result, so the pre-flush harvest below can publish the
463
+ // reasoning before the dashboard form appears. The eventual answer is injected as a normal prompt.
464
+ const response = Response.json(handler.buildHookResponse(
465
+ { approvalId, decision: "deny", reason: PENDING_QUESTION_DENY_REASON },
466
+ body,
467
+ ));
468
+ void awaitPermissionDecision({
469
+ options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput,
470
+ blockedReason: "pendingQuestion",
471
+ recommendedAction: "Answer the question in Agent Relay.",
472
+ suppressResolvedStatus: true,
473
+ }).then(({ decision, view }) => decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length
474
+ ? handler.buildAnswerInjectionPrompt!(view, decision, body)
475
+ : pendingQuestionDismissalPrompt(decision),
476
+ ).then(
477
+ (text) => options.onAnswerInject?.({ approvalId, text }),
478
+ // #2913: the inject is the only release of the runner's question hold — never skip it.
479
+ () => options.onAnswerInject?.({ approvalId }),
480
+ ).catch(() => {});
481
+ return response;
482
+ }
483
+
484
+ // Shared by both the traditional block-until-decided flow and the #1173 deny-then-hold flow:
485
+ // pre-flushes narrative (#435/#1116), builds+persists the interactive view, publishes the
486
+ // blocked providerState, and resolves once a decision arrives (or the wait times out). Callers
487
+ // differ only in what happens with the resolved decision (build a hook response vs. inject an
488
+ // answer prompt), so that part is left to them.
489
+ async function awaitPermissionDecision(params: {
490
+ options: ControlServerOptions;
491
+ pendingPermissionRequests: Map<string, PendingPermissionWaiter>;
492
+ handler: ProviderPermissionPromptHandler;
493
+ approvalId: string;
494
+ body: Record<string, unknown>;
495
+ occurredAt: number;
496
+ transcriptPath: string;
497
+ toolName?: string;
498
+ toolInput?: unknown;
499
+ blockedReason: string;
500
+ recommendedAction: string;
501
+ suppressResolvedStatus?: boolean;
502
+ }): Promise<{ decision: ProviderPermissionDecisionInput; view: InteractivePrompt }> {
503
+ const { options, pendingPermissionRequests, handler, approvalId, body, occurredAt, transcriptPath, toolName, toolInput, blockedReason, recommendedAction, suppressResolvedStatus } = params;
504
+ // #435/#1116: flush any un-mirrored narrative before the blocking form appears in
505
+ // chat. The adapter owns how to settle its capture source; the transport only
506
+ // forwards the hook payload as an anchor and stamps the returned truth value.
507
+ let reasoningSettled = true;
508
+ // Defaults to the hook's own wall-clock receipt time; refined below to the transcript's own
509
+ // tool_use event time when the pre-flush harvest can resolve it (#1185) — the hook can receive
510
+ // before the provider flushes/timestamps that entry, which otherwise sorts the resolved prompt
511
+ // ahead of the narration that led up to it.
512
+ let resolvedOccurredAt = occurredAt;
513
+ if (transcriptPath && options.onSessionTurn) {
514
+ const result = await options.onSessionTurn({ transcriptPath, isPreFlush: true, toolName, toolInput }).catch(() => undefined);
515
+ if (result && typeof result.reasoningSettled === "boolean") reasoningSettled = result.reasoningSettled;
516
+ if (result && typeof result.toolUseOccurredAt === "number") resolvedOccurredAt = result.toolUseOccurredAt;
517
+ }
518
+ const view = handler.buildView(approvalId, body, reasoningSettled);
519
+ const waitMs = options.permissionWaitMs ?? PERMISSION_WAIT_MS;
520
+ const hookDeadlineAt = occurredAt + waitMs;
521
+ bestEffortUpsertPendingPermissionRequest(options.pendingPermissionStore, {
522
+ approvalId,
523
+ view,
524
+ hookDeadlineAt,
525
+ ownerInstanceId: options.instanceId ?? "",
526
+ occurredAt,
527
+ });
528
+ options.onStatus({
529
+ status: "busy",
530
+ reason: "provider-turn",
531
+ providerState: {
532
+ state: "blocked",
533
+ reason: blockedReason,
534
+ label: view.title,
535
+ recommendedAction,
536
+ source: handler.source,
537
+ pendingApproval: view,
538
+ updatedAt: Date.now(),
539
+ },
540
+ });
541
+
542
+ const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
543
+ const timer = setTimeout(() => {
544
+ pendingPermissionRequests.delete(approvalId);
545
+ bestEffortDeletePendingPermissionRequest(options.pendingPermissionStore, approvalId);
546
+ if (!suppressResolvedStatus) {
547
+ options.onStatus({
548
+ status: "busy",
549
+ reason: "provider-turn",
550
+ providerState: {
551
+ state: "active",
552
+ reason: "permissionTimedOut",
553
+ label: "Provider approval timed out",
554
+ source: handler.source,
555
+ updatedAt: Date.now(),
556
+ },
557
+ });
558
+ }
559
+ resolve(withDecisionReason({ approvalId, decision: "deny", reason: permissionTimeoutReason(view) }, "timeout"));
560
+ }, waitMs);
561
+ pendingPermissionRequests.set(approvalId, { resolve, timer, suppressResolvedStatus });
562
+ });
563
+
564
+ // A timeout is a resolved prompt too. Persist it with its distinct timeout reason so
565
+ // the coordinator can see how the hold ended instead of only watching it disappear.
566
+ if (options.onPermissionResolved) {
567
+ await Promise.resolve(options.onPermissionResolved(handler.buildResolvedPrompt(view, decision, body, resolvedOccurredAt))).catch(() => {});
568
+ }
569
+
570
+ return { decision, view };
571
+ }
572
+
573
+ function pendingQuestionDismissalPrompt(decision: ProviderPermissionDecisionInput): string {
574
+ const timedOut = decision.decisionReason?.kind === "timeout";
575
+ const event = timedOut ? "timed out before the user answered" : "was dismissed before the user answered";
576
+ return `Your previous AskUserQuestion ${event}. Proceed on your best judgment, or re-ask if you still need a decision.`;
577
+ }
578
+
579
+ async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
580
+ if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
581
+ const body = await req.json().catch(() => null);
582
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
583
+ const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
584
+ const promptId = isRecord(body) && typeof body.promptId === "string" && body.promptId ? body.promptId : undefined;
585
+ // transcriptPath is the primary source, but it can be absent (known CC Stop edge case).
586
+ // lastAssistantMessage is the fallback for exactly that — accept either; reject only when
587
+ // both are missing so the turn still reaches the dashboard chat (#264).
588
+ if (!transcriptPath && lastAssistantMessage === undefined) {
589
+ return Response.json({ ok: false, reason: "transcriptPath or lastAssistantMessage required" }, { status: 400 });
590
+ }
591
+ // Awaited on purpose: the Stop hook posts this synchronously before its
592
+ // reply-obligation check, so the captured turn must be persisted (and the
593
+ // obligation cleared) before this response returns.
594
+ try {
595
+ await options.onSessionTurn({ transcriptPath, lastAssistantMessage, ...(promptId ? { promptId } : {}) });
596
+ return Response.json({ ok: true });
597
+ } catch (error) {
598
+ return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
599
+ }
600
+ }
601
+
602
+ /** `?firedAt=<epoch ms>`: when the hook ran, stamped by the hook itself (#2664). */
603
+ function hookFiredAt(url: URL): { firedAt?: number } {
604
+ const raw = url.searchParams.get("firedAt") ?? "";
605
+ const firedAt = /^\d{1,16}$/.test(raw) ? Number(raw) : NaN;
606
+ return Number.isSafeInteger(firedAt) ? { firedAt } : {};
607
+ }
608
+
609
+ // The body is the provider hook's raw stdin payload (Claude: prompt/transcript_path/prompt_id).
610
+ async function handleUserPrompt(req: Request, url: URL, options: ControlServerOptions): Promise<Response> {
611
+ if (!options.onUserPrompt) return Response.json({ ok: false, reason: "prompt echo unavailable" });
612
+ const body = await req.json().catch(() => null);
613
+ const field = (key: string) => isRecord(body) && typeof body[key] === "string" && body[key] ? body[key] as string : undefined;
614
+ const prompt = field("prompt") ?? "";
615
+ if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
616
+ const transcriptPath = field("transcript_path");
617
+ const promptId = field("prompt_id");
618
+ const { firedAt } = hookFiredAt(url);
619
+ // Fire-and-forget: the hook must not block the provider's turn on relay round-trips.
620
+ void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath, ...(promptId ? { promptId } : {}), ...(firedAt !== undefined ? { firedAt } : {}) })).catch(() => {});
621
+ return Response.json({ ok: true });
622
+ }
623
+
624
+ async function handleSessionBoundary(req: Request, options: ControlServerOptions): Promise<Response> {
625
+ if (!options.onSessionBoundary) return Response.json({ ok: false, reason: "session-boundary capture unavailable" });
626
+ const body = await req.json().catch(() => null);
627
+ const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
628
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
629
+ // Fire-and-forget: a lifecycle hook must not block the provider compacting or exiting.
630
+ void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
631
+ return Response.json({ ok: true });
632
+ }
633
+
634
+ async function handleLogLevel(req: Request): Promise<Response> {
635
+ const body = await req.json().catch(() => null);
636
+ const level = parseLogLevel(isRecord(body) && typeof body.level === "string" ? body.level : undefined);
637
+ if (!level) return Response.json({ error: `level must be one of: ${LOG_LEVELS.join(", ")}` }, { status: 400 });
638
+ const previous = logger.getLevel();
639
+ logger.setLevel(level);
640
+ logger.info("logger", `log level set to ${level} (was ${previous}) via control port`);
641
+ return Response.json({ ok: true, level, previous });
642
+ }
643
+
644
+ async function handleHookFatal(req: Request, options: ControlServerOptions): Promise<Response> {
645
+ const body = await req.json().catch(() => null);
646
+ const hook = isRecord(body) && typeof body.hook === "string" && body.hook.trim() ? body.hook.trim() : "unknown";
647
+ const error = isRecord(body) && typeof body.error === "string" ? body.error : "(no detail)";
648
+ // Never silent: a hook that couldn't handle its own failure lands here as FATAL.
649
+ logger.fatal(`hook:${hook}`, error);
650
+ try { options.onHookFatal?.({ hook, error }); } catch { /* reporting must never throw back at the hook */ }
651
+ return Response.json({ ok: true });
652
+ }
653
+
654
+ async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
655
+ const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
656
+ const status = body?.status;
657
+ if (status !== "idle" && status !== "busy" && status !== "offline" && status !== "error") {
658
+ return Response.json({ error: "status must be idle, busy, offline, or error" }, { status: 400 });
659
+ }
660
+ const reason = body?.reason;
661
+ if (reason !== undefined && reason !== "provider-turn" && reason !== "subagent" && reason !== "background-script") {
662
+ return Response.json({ error: "reason must be provider-turn, subagent, or background-script" }, { status: 400 });
663
+ }
664
+ const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
665
+ item === "provider-turn" || item === "subagent" || item === "background-script";
666
+ const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
667
+ // #650: optional pids the runner reconciles against process liveness so a tracked
668
+ // command/shell killed out-of-band clears the busy marker instead of stranding it.
669
+ const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
670
+ const pids = pidsInput
671
+ ?.map((p) => (typeof p === "number" ? p : Number(p)))
672
+ .filter((p) => Number.isInteger(p) && p > 0);
673
+ const timeline = statusTimelineEvent(body);
674
+ const update: ProviderStatusEvent = {
675
+ status,
676
+ ...(typeof body?.providerSessionId === "string" ? { providerSessionId: body.providerSessionId } : {}),
677
+ ...(reason ? { reason } : {}),
678
+ ...(clear?.length ? { clear: [...new Set(clear)] } : {}),
679
+ ...(timeline ? { timeline } : {}),
680
+ ...(typeof body?.id === "string" ? { id: body.id } : {}),
681
+ ...(pids?.length ? { pids } : {}),
682
+ ...(typeof body?.label === "string" ? { label: body.label } : {}),
683
+ ...(typeof body?.role === "string" ? { role: body.role } : {}),
684
+ ...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
685
+ ...(body?.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? { metadata: body.metadata as Record<string, unknown> } : {}),
686
+ };
687
+ options.onStatus(update);
688
+ return Response.json({ ok: true, ...update });
689
+ }
690
+
691
+ // #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
692
+ // provider-neutral `blocked` state (reason rate_limit) so the dashboard shows it
693
+ // distinctly (never as idle/available) and the relay's resume sweep can lift it
694
+ // at reset. The agent stays `idle` underneath (the turn truly ended); the runner
695
+ // carries the hold via its rateLimitHold field, not an active-work claim.
696
+ async function handleRateLimit(req: Request, options: ControlServerOptions): Promise<Response> {
697
+ const body = await req.json().catch(() => null);
698
+ if (!isRecord(body)) return Response.json({ error: "invalid body" }, { status: 400 });
699
+ const errorType = typeof body.errorType === "string" && body.errorType ? body.errorType : "rate_limit";
700
+ // CC's reset_time is unix SECONDS ("if available"); normalize to ms for the relay.
701
+ const resetTimeSec = typeof body.resetTime === "number" && Number.isFinite(body.resetTime) ? body.resetTime : undefined;
702
+ const resetAt = resetTimeSec !== undefined ? Math.round(resetTimeSec * 1000) : undefined;
703
+ const errorMessage = typeof body.errorMessage === "string" && body.errorMessage ? body.errorMessage : undefined;
704
+ options.onStatus({
705
+ status: "idle",
706
+ clear: ["subagent"],
707
+ providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: options.providerSourceId ?? "provider" }),
708
+ });
709
+ return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
710
+ }
711
+
712
+ function statusTimelineEvent(body: Partial<ProviderStatusEvent> | null): ProviderStatusEvent["timeline"] | undefined {
713
+ const timeline = body?.timeline;
714
+ if (!timeline || typeof timeline !== "object" || Array.isArray(timeline)) return undefined;
715
+ if (typeof timeline.status !== "string" || !timeline.status) return undefined;
716
+ return {
717
+ status: timeline.status,
718
+ ...(typeof timeline.id === "string" ? { id: timeline.id } : {}),
719
+ ...(typeof timeline.timestamp === "number" && Number.isFinite(timeline.timestamp) ? { timestamp: timeline.timestamp } : {}),
720
+ };
721
+ }
722
+
723
+ function parseJson(raw: string | Buffer): Record<string, unknown> | null {
724
+ try {
725
+ const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString());
726
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
727
+ } catch {
728
+ return null;
729
+ }
730
+ }