@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,290 @@
1
+ // #1647 — the ONE module allowed to type Claude's exit command, or the Enter that
2
+ // answers its exit-confirmation dialog, into a managed pane.
3
+ //
4
+ // A 13.4h coordinator was destroyed when the runner's graceful-shutdown path typed
5
+ // `/exit` into a pane whose turn was LIVE (a turn had started 18s earlier and never
6
+ // ended), and the shutdown grace loop then pressed Enter on the resulting
7
+ // "Background work is running — 1. Exit anyway" modal. The shutdown request itself
8
+ // was legitimate and audited; what was missing was any correlation between the
9
+ // request and the state of the process it was about to kill.
10
+ //
11
+ // Two structural guarantees live here, and both are enforced at RUNTIME, not by
12
+ // convention:
13
+ //
14
+ // 1. CAPABILITY. Neither writer takes a session name. Both take a ClaudeExitGrant,
15
+ // and a grant can only come out of `authorizeClaudeGracefulExit`, which in turn
16
+ // demands a shutdown intent minted by an explicit lifecycle command. Grants are
17
+ // tracked in a module-private WeakSet, so a forged object literal is rejected —
18
+ // a detector cannot reach these functions even by fabricating the type.
19
+ //
20
+ // 2. CORRELATION. Authorization requires the observed provider epoch, process
21
+ // identity, tmux session, provider session id and turn state to match the
22
+ // intent EXACTLY, with positive evidence the turn has ended. Anything else —
23
+ // a live turn, an UNKNOWN turn, a stale intent, a re-used intent — is denied.
24
+ // Fail-closed in every direction: absent evidence is never treated as consent.
25
+ //
26
+ // `claude-exit.guard.test.ts` fails the build if any pane-detector module can reach
27
+ // this file through the import graph.
28
+ import { isClaudeExitConfirmationDialog } from "@humwick/sdk/claude-exit-dialog";
29
+ import { tmuxCommand } from "@humwick/sdk/tmux-utils";
30
+ import { spawnSyncBounded } from "../bounded-spawn-sync";
31
+ import { captureTmuxPane, submitTextToTmux } from "./claude-tmux";
32
+
33
+ export const CLAUDE_EXIT_COMMAND = "/exit";
34
+
35
+ // A shutdown intent describes the process the operator asked to stop. It stops
36
+ // being usable once the request is older than this, so a shutdown that queued
37
+ // behind a slow teardown can never fire against a later turn of the same session.
38
+ export const CLAUDE_SHUTDOWN_INTENT_TTL_MS = 60_000;
39
+
40
+ // What the pane/probe says about the correlated turn. "unknown" is NOT "probably
41
+ // idle" — it is the absence of evidence, and it denies exactly like "live".
42
+ export type ClaudeTurnState = "ended" | "live" | "unknown";
43
+
44
+ /**
45
+ * The audited lifecycle request, pinned to the exact process it was issued for.
46
+ * Minted only from an explicit shutdown/restart command — never from a detector.
47
+ */
48
+ export interface ClaudeShutdownIntent {
49
+ /** Runner provider epoch: bumped on every provider spawn, so a post-restart process never matches. */
50
+ readonly epoch: number;
51
+ /** OS pid of the managed process at request time (undefined for tmux-managed panes). */
52
+ readonly pid?: number;
53
+ /** Claude's own process-start marker from the intrinsic session probe. */
54
+ readonly procStart: string | null;
55
+ readonly sessionName: string;
56
+ /** Claude's session id from the intrinsic probe; null when the probe is unavailable. */
57
+ readonly providerSessionId: string | null;
58
+ /**
59
+ * Identity of the conversation the request was accepted against. Claude's intrinsic
60
+ * probe exposes no per-turn id, so this is the finest granularity actually available
61
+ * (see `claudeTurnKey`) and it catches process/session replacement, not turn rollover.
62
+ * The "don't type into a live turn" guarantee therefore rests on the `turn` gate
63
+ * below, NOT on this field — stated plainly so nobody reads more safety into it.
64
+ */
65
+ readonly turnKey: string;
66
+ readonly issuedAt: number;
67
+ }
68
+
69
+ /** Live facts about the pane, sampled at the moment we consider typing `/exit`. */
70
+ export interface ClaudeExitObservation {
71
+ readonly epoch: number;
72
+ readonly pid?: number;
73
+ readonly procStart: string | null;
74
+ readonly sessionName: string;
75
+ readonly providerSessionId: string | null;
76
+ readonly turn: ClaudeTurnState;
77
+ readonly turnKey: string;
78
+ readonly now: number;
79
+ }
80
+
81
+ export type ClaudeExitDenial =
82
+ | "intent-consumed"
83
+ | "intent-stale"
84
+ | "epoch-mismatch"
85
+ | "process-identity-mismatch"
86
+ | "session-mismatch"
87
+ | "provider-session-mismatch"
88
+ | "provider-session-unknown"
89
+ | "turn-changed"
90
+ | "turn-live"
91
+ | "turn-unknown";
92
+
93
+ // Denials that no amount of further polling can clear: the request does not describe
94
+ // the process in front of us, or it has already been spent. The shutdown loop stops
95
+ // waiting on these and goes straight to out-of-band termination. Everything else
96
+ // ("turn-live", "turn-unknown", "provider-session-unknown") is a not-yet, so the loop
97
+ // keeps sampling until the grace window closes.
98
+ const PERMANENT_DENIALS: ReadonlySet<ClaudeExitDenial> = new Set([
99
+ "intent-consumed", "intent-stale", "epoch-mismatch",
100
+ "process-identity-mismatch", "session-mismatch", "provider-session-mismatch", "turn-changed",
101
+ ]);
102
+
103
+ export function claudeExitDenialIsPermanent(reason: ClaudeExitDenial): boolean {
104
+ return PERMANENT_DENIALS.has(reason);
105
+ }
106
+
107
+ export type ClaudeExitDecision =
108
+ | { readonly authorized: true; readonly grant: ClaudeExitGrant }
109
+ | { readonly authorized: false; readonly reason: ClaudeExitDenial };
110
+
111
+ declare const CLAUDE_EXIT_GRANT: unique symbol;
112
+
113
+ /**
114
+ * One-shot capability to type `/exit` into one specific pane, and to answer the
115
+ * ONE dialog that `/exit` raises. Opaque and unforgeable: the brand blocks it at
116
+ * compile time and the ISSUED registry blocks it at runtime.
117
+ */
118
+ export interface ClaudeExitGrant {
119
+ readonly [CLAUDE_EXIT_GRANT]: true;
120
+ readonly sessionName: string;
121
+ readonly socketName?: string;
122
+ readonly authorizedAt: number;
123
+ }
124
+
125
+ interface GrantState {
126
+ /** Set once `/exit` has actually been written through this grant. */
127
+ exitSubmittedAt?: number;
128
+ /** True when a dialog was already on screen BEFORE our `/exit` — it is not ours to answer. */
129
+ dialogPredatesExit: boolean;
130
+ answeredAt?: number;
131
+ }
132
+
133
+ // Module-private. Nothing outside this file can add to either, which is what makes
134
+ // a fabricated grant object inert rather than merely type-invalid.
135
+ const ISSUED_GRANTS = new WeakSet<ClaudeExitGrant>();
136
+ const GRANT_STATE = new WeakMap<ClaudeExitGrant, GrantState>();
137
+ const CONSUMED_INTENTS = new WeakSet<ClaudeShutdownIntent>();
138
+
139
+ /** Record the audited lifecycle request. Minting does not authorize anything by itself. */
140
+ export function mintClaudeShutdownIntent(facts: Omit<ClaudeShutdownIntent, "issuedAt"> & { issuedAt?: number }): ClaudeShutdownIntent {
141
+ return {
142
+ epoch: facts.epoch,
143
+ ...(facts.pid === undefined ? {} : { pid: facts.pid }),
144
+ procStart: facts.procStart,
145
+ sessionName: facts.sessionName,
146
+ providerSessionId: facts.providerSessionId,
147
+ turnKey: facts.turnKey,
148
+ issuedAt: facts.issuedAt ?? Date.now(),
149
+ };
150
+ }
151
+
152
+ /**
153
+ * The whole policy, as a pure decision over (intent, observation). Kept separate
154
+ * from grant minting so the rules are unit-testable without touching tmux.
155
+ */
156
+ export function decideClaudeGracefulExit(
157
+ intent: ClaudeShutdownIntent,
158
+ observed: ClaudeExitObservation,
159
+ consumed: boolean,
160
+ ): ClaudeExitDenial | undefined {
161
+ if (consumed) return "intent-consumed";
162
+ if (observed.now - intent.issuedAt > CLAUDE_SHUTDOWN_INTENT_TTL_MS) return "intent-stale";
163
+ if (observed.epoch !== intent.epoch) return "epoch-mismatch";
164
+ if (observed.sessionName !== intent.sessionName) return "session-mismatch";
165
+ // pid/procStart: only compare what BOTH sides know. A pid that changed under us is
166
+ // decisive; a pid nobody recorded cannot be, so it falls through to the turn gate.
167
+ if (intent.pid !== undefined && observed.pid !== undefined && intent.pid !== observed.pid) return "process-identity-mismatch";
168
+ if (intent.procStart !== null && observed.procStart !== null && intent.procStart !== observed.procStart) return "process-identity-mismatch";
169
+ // The provider session id is the strongest identity we have, so absence of it at
170
+ // exit time is disqualifying rather than ignorable: without it we cannot tell this
171
+ // session from its successor.
172
+ if (observed.providerSessionId === null) return "provider-session-unknown";
173
+ if (intent.providerSessionId !== null && intent.providerSessionId !== observed.providerSessionId) return "provider-session-mismatch";
174
+ // #1647's actual failure: the pane was mid-stream. Only POSITIVE end-of-turn
175
+ // evidence authorizes typing into it.
176
+ if (observed.turn === "live") return "turn-live";
177
+ if (observed.turn !== "ended") return "turn-unknown";
178
+ // An "ended" verdict for a DIFFERENT turn than the one we were asked about means a
179
+ // new turn started and finished since; the request no longer describes reality.
180
+ if (observed.turnKey !== intent.turnKey) return "turn-changed";
181
+ return undefined;
182
+ }
183
+
184
+ /**
185
+ * Consume the intent and mint a grant, or explain the denial. One-shot: the intent
186
+ * is burned on the first authorization, so a retry loop cannot type `/exit` twice.
187
+ */
188
+ export function authorizeClaudeGracefulExit(
189
+ intent: ClaudeShutdownIntent,
190
+ observed: ClaudeExitObservation,
191
+ socketName?: string,
192
+ ): ClaudeExitDecision {
193
+ const denial = decideClaudeGracefulExit(intent, observed, CONSUMED_INTENTS.has(intent));
194
+ if (denial) return { authorized: false, reason: denial };
195
+ CONSUMED_INTENTS.add(intent);
196
+ const grant = {
197
+ sessionName: intent.sessionName,
198
+ ...(socketName === undefined ? {} : { socketName }),
199
+ authorizedAt: observed.now,
200
+ } as ClaudeExitGrant;
201
+ ISSUED_GRANTS.add(grant);
202
+ GRANT_STATE.set(grant, { dialogPredatesExit: false });
203
+ return { authorized: true, grant };
204
+ }
205
+
206
+ export interface ClaudeExitWriterDeps {
207
+ capture?: (sessionName: string, socketName?: string) => string;
208
+ submit?: (sessionName: string, text: string, socketName?: string) => Promise<void>;
209
+ sendEnter?: (sessionName: string, socketName?: string) => void;
210
+ }
211
+
212
+ function grantStateOrThrow(grant: ClaudeExitGrant): GrantState {
213
+ // The runtime half of the capability check. A detector that fabricates
214
+ // `{} as ClaudeExitGrant` to satisfy the compiler still lands here and throws.
215
+ if (!ISSUED_GRANTS.has(grant)) throw new Error("claude exit: grant was not issued by authorizeClaudeGracefulExit");
216
+ const state = GRANT_STATE.get(grant);
217
+ if (!state) throw new Error("claude exit: grant has no state");
218
+ return state;
219
+ }
220
+
221
+ export type ClaudeExitSubmitResult = "submitted" | "already-submitted" | "dialog-already-open";
222
+
223
+ /**
224
+ * Type `/exit` into the granted pane, once.
225
+ *
226
+ * Snapshots the pane first: if an exit dialog is ALREADY on screen we neither type
227
+ * nor later answer it, because a modal we did not cause is not ours to dismiss —
228
+ * that is the "causally bound" half of the guard.
229
+ */
230
+ export async function submitClaudeExitCommand(grant: ClaudeExitGrant, deps: ClaudeExitWriterDeps = {}): Promise<ClaudeExitSubmitResult> {
231
+ const state = grantStateOrThrow(grant);
232
+ if (state.exitSubmittedAt !== undefined) return "already-submitted";
233
+ const capture = deps.capture ?? captureTmuxPane;
234
+ const submit = deps.submit ?? submitTextToTmux;
235
+ let pane: string | undefined;
236
+ try {
237
+ pane = capture(grant.sessionName, grant.socketName);
238
+ } catch {
239
+ // Capture failure is not evidence of a clean pane. Fail closed and let the
240
+ // caller's out-of-band kill-session backstop terminate the process instead.
241
+ state.dialogPredatesExit = true;
242
+ return "dialog-already-open";
243
+ }
244
+ if (isClaudeExitConfirmationDialog(pane)) {
245
+ state.dialogPredatesExit = true;
246
+ return "dialog-already-open";
247
+ }
248
+ await submit(grant.sessionName, CLAUDE_EXIT_COMMAND, grant.socketName);
249
+ state.exitSubmittedAt = Date.now();
250
+ return "submitted";
251
+ }
252
+
253
+ export type ClaudeExitConfirmResult = "confirmed" | "no-dialog" | "exit-not-submitted" | "dialog-predates-exit" | "already-answered";
254
+
255
+ /**
256
+ * Answer the exit-confirmation dialog raised by OUR `/exit`, once.
257
+ *
258
+ * Every non-"confirmed" result means no key was written. In particular the Enter is
259
+ * unreachable unless this same grant already submitted `/exit` and no dialog was
260
+ * open beforehand — so it can only ever dismiss the modal it caused.
261
+ */
262
+ export function confirmClaudeExitDialogUnderGrant(grant: ClaudeExitGrant, deps: ClaudeExitWriterDeps = {}): ClaudeExitConfirmResult {
263
+ const state = grantStateOrThrow(grant);
264
+ if (state.dialogPredatesExit) return "dialog-predates-exit";
265
+ if (state.exitSubmittedAt === undefined) return "exit-not-submitted";
266
+ if (state.answeredAt !== undefined) return "already-answered";
267
+ const capture = deps.capture ?? captureTmuxPane;
268
+ let pane: string;
269
+ try {
270
+ pane = capture(grant.sessionName, grant.socketName);
271
+ } catch {
272
+ return "no-dialog";
273
+ }
274
+ if (!isClaudeExitConfirmationDialog(pane)) return "no-dialog";
275
+ const sendEnter = deps.sendEnter ?? defaultSendEnter;
276
+ try {
277
+ sendEnter(grant.sessionName, grant.socketName);
278
+ } catch {
279
+ // Best-effort: the caller's kill-session backstop still guarantees termination.
280
+ return "no-dialog";
281
+ }
282
+ state.answeredAt = Date.now();
283
+ return "confirmed";
284
+ }
285
+
286
+ function defaultSendEnter(sessionName: string, socketName?: string): void {
287
+ spawnSyncBounded(tmuxCommand(socketName, "send-keys", "-t", sessionName, "Enter"), {
288
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
289
+ });
290
+ }
@@ -0,0 +1,88 @@
1
+ import { isRecord, type ContextState } from "@humwick/sdk";
2
+ import type { ProviderSessionEvent } from "../adapter";
3
+ import { freshContextState, providerContextState } from "../transcript-context";
4
+ import { claudeAssistantSteps, claudeToolResults, sumUsageTokens, turnStepEvent, type TranscriptMessage, type TurnStep } from "./claude-transcript";
5
+
6
+ /**
7
+ * Turns the root session's stream-json events into session-mirror steps and context usage (#2771).
8
+ * A tool row opens `running` on its tool_use and settles in place (stepId = tool_use id) on its tool_result.
9
+ */
10
+ export class ClaudeHeadlessMirror {
11
+ private readonly openTools = new Map<string, TurnStep>();
12
+ private readonly contextWindows = new Map<string, number>();
13
+ private usage?: { tokensUsed: number; model?: string; at: number };
14
+ private clearedAt?: number;
15
+
16
+ observe(event: Record<string, unknown>, turnId: string | undefined): ProviderSessionEvent[] {
17
+ // Subagent events carry their parent's tool_use id; like transcript sidechains they stay out of the root mirror.
18
+ if (event.parent_tool_use_id) return [];
19
+ const message = isRecord(event.message) ? event.message as TranscriptMessage : undefined;
20
+ const occurredAt = typeof event.timestamp === "string" ? Date.parse(event.timestamp) || undefined : undefined;
21
+ if (event.type === "assistant") {
22
+ this.noteUsage(message, occurredAt);
23
+ // Claude's synthetic API-error message is a transcript row, not model output.
24
+ const diagnostic = event.is_api_error_message === true;
25
+ return claudeAssistantSteps(message, occurredAt).map((step) => {
26
+ if (step.type !== "tool" || !step.toolUseId) return stepEvent(step, turnId, undefined, diagnostic);
27
+ this.openTools.set(step.toolUseId, step);
28
+ return stepEvent(step, turnId, "running");
29
+ });
30
+ }
31
+ if (event.type === "user") {
32
+ return claudeToolResults(message).flatMap(({ toolUseId, isError }) => {
33
+ const step = this.openTools.get(toolUseId);
34
+ if (!step) return [];
35
+ this.openTools.delete(toolUseId);
36
+ return [stepEvent(step, turnId, isError ? "failed" : "completed")];
37
+ });
38
+ }
39
+ if (event.type !== "result") return [];
40
+ if (isRecord(event.modelUsage)) {
41
+ for (const [model, usage] of Object.entries(event.modelUsage)) {
42
+ if (isRecord(usage) && typeof usage.contextWindow === "number" && usage.contextWindow > 0) this.contextWindows.set(model, usage.contextWindow);
43
+ }
44
+ }
45
+ // A tool with no result by the turn's end was cut off (interrupt or abort).
46
+ const cut = [...this.openTools.values()].map((step) => stepEvent(step, turnId, "cancelled"));
47
+ this.openTools.clear();
48
+ return cut;
49
+ }
50
+
51
+ /** Context after a compaction: its reported post size, or unknown until the next turn. */
52
+ resetContext(tokensUsed?: number): void {
53
+ this.usage = tokensUsed && tokensUsed > 0 ? { tokensUsed, model: this.usage?.model, at: Date.now() } : undefined;
54
+ this.clearedAt = undefined;
55
+ }
56
+
57
+ /** After a clear the context is explicitly empty; omitting it would leave the card on the old usage. */
58
+ clearContext(): void {
59
+ this.usage = undefined;
60
+ this.clearedAt = Date.now();
61
+ }
62
+
63
+ context(): ContextState | undefined {
64
+ const usage = this.usage;
65
+ const onlyWindow = this.contextWindows.size === 1 ? [...this.contextWindows.values()][0] : undefined;
66
+ if (!usage) return this.clearedAt ? freshContextState(this.clearedAt, onlyWindow) : undefined;
67
+ const window = (usage.model ? this.contextWindows.get(usage.model) : undefined) ?? onlyWindow;
68
+ return window ? providerContextState(usage.tokensUsed, window, usage.at) : undefined;
69
+ }
70
+
71
+ private noteUsage(message: TranscriptMessage | undefined, occurredAt: number | undefined): void {
72
+ const tokensUsed = message?.usage ? sumUsageTokens(message.usage) : undefined;
73
+ // A synthetic API-error message reports zero usage; that is no reading, not an empty context.
74
+ if (!tokensUsed) return;
75
+ this.usage = { tokensUsed, model: typeof message?.model === "string" ? message.model : undefined, at: occurredAt ?? Date.now() };
76
+ this.clearedAt = undefined;
77
+ }
78
+ }
79
+
80
+ function stepEvent(step: TurnStep, turnId: string | undefined, status?: ProviderSessionEvent["status"], diagnostic = false): ProviderSessionEvent {
81
+ return {
82
+ ...turnStepEvent(step, turnId),
83
+ ...(status ? { status } : {}),
84
+ ...(status === "running" ? { streaming: true } : {}),
85
+ ...(step.toolUseId ? { stepId: step.toolUseId } : {}),
86
+ ...(diagnostic ? { diagnostic: true } : {}),
87
+ };
88
+ }
@@ -0,0 +1,51 @@
1
+ import type { ProviderState } from "@humwick/sdk";
2
+ import { buildProviderRefusalHold, isProviderOverload, isProviderThrottle, isProviderUsageLimit, isProxyAuthFailure, parseClaudeRateLimitPane } from "../rate-limit";
3
+
4
+ /** What Claude's native error result says about a refused turn (#2771). */
5
+ export interface ClaudeTurnError {
6
+ /** The synthetic API-error assistant message's `error` class (rate_limit, overloaded, ...). */
7
+ error?: string;
8
+ status?: number;
9
+ text: string;
10
+ }
11
+
12
+ // The classes the interactive StopFailure hook holds on (stop-failure.sh); anything else stays a terminal error.
13
+ const HOLD_ERRORS = new Set(["rate_limit", "overloaded", "server_error", "api_error"]);
14
+
15
+ /** The native error an assistant event carries when the API refused the request. */
16
+ export function claudeApiErrorOf(event: Record<string, unknown>): string | undefined {
17
+ return event.type === "assistant" && event.is_api_error_message === true && typeof event.error === "string" ? event.error : undefined;
18
+ }
19
+
20
+ /** Error text is authoritative even when Claude omits its API-error metadata (#3544). */
21
+ export function claudeTurnError(result: Record<string, unknown>, error: string | undefined): ClaudeTurnError | undefined {
22
+ if (result.is_error !== true && result.terminal_reason !== "api_error" && typeof result.api_error_status !== "number" && error === undefined) return undefined;
23
+ const text = typeof result.result === "string" ? result.result : "";
24
+ const textStatus = /^API Error:\s*(\d{3})\b/i.exec(text);
25
+ const status = typeof result.api_error_status === "number" ? result.api_error_status : textStatus ? Number(textStatus[1]) : undefined;
26
+ return { error: error ?? (/\boverload(?:ed)?\b/i.test(text) ? "overloaded" : undefined), status, text };
27
+ }
28
+
29
+ /**
30
+ * The provider-neutral rate-limit hold for a refused turn (throttle, quota wall or overload), or
31
+ * undefined for an error a hold cannot clear (auth, billing, invalid request).
32
+ */
33
+ export function claudeRefusalHold(turn: ClaudeTurnError, source: string): ProviderState | undefined {
34
+ const refusal = { message: turn.text, errorType: turn.error, statusCode: turn.status };
35
+ const retryable = (turn.error !== undefined && HOLD_ERRORS.has(turn.error)) || isProviderThrottle(refusal) || isProviderUsageLimit(refusal)
36
+ || isProviderOverload(refusal) || isProxyAuthFailure(turn.text);
37
+ if (!retryable) return undefined;
38
+ // Subscription walls can state a local-clock reset; the shared hold parses API timestamps.
39
+ const resetAt = parseClaudeRateLimitPane(turn.text)?.resetAt;
40
+ return buildProviderRefusalHold({ message: turn.text, detail: turn.text || `Claude ${turn.error ?? turn.status}`, errorType: turn.error, statusCode: turn.status, source, ...(resetAt ? { resetAt } : {}) });
41
+ }
42
+
43
+ /** Preserve the provider's cause even when its error result has subtype=success (#3544). */
44
+ export function claudeResultFailure(result: Record<string, unknown>) {
45
+ const text = typeof result.result === "string" ? result.result.trim() : "";
46
+ const budget = result.subtype === "error_max_budget_usd" || /\b(?:budget|allowance)\b.*\b(?:exceeded|exhausted|reached)\b/i.test(text);
47
+ return {
48
+ terminalFailureReason: budget ? "headless-budget-exhausted" : "headless-result-error",
49
+ terminalFailureMessage: `Claude headless result: ${String(result.subtype ?? "invalid result")}${text ? `: ${text.slice(0, 2000)}${text.length > 2000 ? "…" : ""}` : ""}`,
50
+ };
51
+ }