@humwick/runner 0.0.0-stage → 0.130.1

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 +42 -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,414 @@
1
+ // #766/#768 — Claude Code's intrinsic, machine-readable session probe.
2
+ //
3
+ // Claude Code ≥2.1.119 writes `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` and rewrites
4
+ // it on every status transition. It carries an authoritative `status` field
5
+ // (busy|shell|idle|waiting, plus `waitingFor` when waiting) — the very
6
+ // "machine-readable ready/busy signal" the old pane-scrape comment claimed didn't
7
+ // exist. The runner manages the claude pid and its CLAUDE_CONFIG_DIR, so this is a
8
+ // direct keyed read — no enumeration, no cwd/solo-multi disambiguation.
9
+ //
10
+ // This is the single home (DRY) for reading + version-gating + mapping that probe.
11
+ // `probeActivity`, the BusyReconciler backstop, and the monitorless turn-watch all
12
+ // delegate here; the legacy pane-scrape stays only as the graceful fallback for
13
+ // pre-2.1.119 / probe-missing sessions (e.g. a claude-rig host whose real config
14
+ // home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
15
+ // claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
16
+ // Anthropic ever drifts it.
17
+ import { readFile, stat } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { isPidAlive } from "@humwick/sdk/process-utils";
21
+ import type { ManagedProcess } from "../adapter";
22
+ import { resolveClaudePanePidAsync } from "./tmux-offload";
23
+
24
+ /** Raw probe status; "unknown" = absent / unparseable / version-gated out. */
25
+ export type ClaudeProbeStatus = "busy" | "shell" | "idle" | "waiting" | "unknown";
26
+
27
+ /** The 3-valued activity the reconciler + monitorless turn-watch consume. */
28
+ export type ClaudeProbeActivity = "busy" | "idle" | "unknown";
29
+
30
+ /** First Claude Code version that writes the session probe. Below this → fallback. */
31
+ export const CLAUDE_PROBE_MIN_VERSION = "2.1.119";
32
+
33
+ /**
34
+ * The probe half of an activity read: the mapped 3-valued activity (undefined when the probe is
35
+ * absent/old/unparseable → the caller falls back to the pane) plus `busyStale`: the busy verdict
36
+ * predates a turn-cancelling keystroke relay itself sent (#1621). Age never discredits it (#3026):
37
+ * Claude writes neither probe nor transcript during a long think, so a frozen busy is held and the
38
+ * notify-only prolonged-hold watchdog surfaces a turn that really died out of band.
39
+ */
40
+ export function claudeProbeVerdict(
41
+ reading: ClaudeSessionReading | undefined,
42
+ opts: { paneInterruptAt?: number; lastActivityAt?: number } = {},
43
+ ): { activity: ClaudeProbeActivity | undefined; busyStale: boolean } {
44
+ const activity = reading?.available ? claudeProbeActivity(reading.status) : undefined;
45
+ if (activity !== "busy" || !reading || opts.paneInterruptAt === undefined || reading.updatedAt === null) {
46
+ return { activity, busyStale: false };
47
+ }
48
+ // #1642 — a transcript append after the interrupt means the cancel did not take.
49
+ const cancelDidNotTake = opts.lastActivityAt !== undefined && opts.lastActivityAt > opts.paneInterruptAt;
50
+ return { activity, busyStale: reading.updatedAt <= opts.paneInterruptAt && !cancelDidNotTake };
51
+ }
52
+
53
+ export interface ClaudeSessionReading {
54
+ /** Raw probe status, or "unknown" when the probe is absent/old/unparseable. */
55
+ status: ClaudeProbeStatus;
56
+ /** `waitingFor` reason when status==="waiting" (e.g. "approve Bash"), else null. */
57
+ waitingFor: string | null;
58
+ /** Probe `version`, or null when unavailable. */
59
+ version: string | null;
60
+ /** `procStart` (process start-time token: `/proc/<pid>/stat` field 22, or UTC `ps` lstart off Linux), or null. */
61
+ procStart: string | null;
62
+ /** `updatedAt` epoch-ms of the last probe write, or null. */
63
+ updatedAt: number | null;
64
+ /** Claude's own session UUID (#1235) — names the transcript file. Null when absent. */
65
+ sessionId: string | null;
66
+ /** The session's cwd (#1235) — slugified into the transcript's project dir. Null when absent. */
67
+ cwd: string | null;
68
+ /** True iff a present, parseable, version-gated probe produced `status`. */
69
+ available: boolean;
70
+ }
71
+
72
+ const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, sessionId: null, cwd: null, available: false };
73
+
74
+ /**
75
+ * Process-liveness seam for the staleness guard (#769 P1-B), injectable in tests.
76
+ * procStartOf — the start-time token of a LIVE pid (to compare against the probe's
77
+ * `procStart`), or null when the pid is dead/unreadable. Catches both a frozen
78
+ * probe from a SIGKILLed claude AND a sub-second pid-reuse window (a reused pid
79
+ * has a different start time).
80
+ * pidAlive — liveness fallback for probes that predate `procStart`.
81
+ */
82
+ export interface ClaudeProbeLivenessAsync {
83
+ procStartOf(pid: number): Promise<string | null>;
84
+ pidAlive(pid: number): boolean;
85
+ }
86
+
87
+ /** field 22 (starttime) of a `/proc/<pid>/stat` body; comm (field 2) may hold spaces and ')'. */
88
+ function procStatStartTime(stat: string): string | null {
89
+ return stat.slice(stat.lastIndexOf(")") + 1).trim().split(/\s+/)[19] ?? null;
90
+ }
91
+
92
+ // #1838 rows 14/15 — off-loop: the 4s reconciler / 1.5s turn-watch tick never blocks on the read.
93
+ async function readProcStartTimeAsync(pid: number): Promise<string | null> {
94
+ try {
95
+ return procStatStartTime(await readFile(`/proc/${pid}/stat`, "utf8"));
96
+ } catch {
97
+ return null;
98
+ }
99
+ }
100
+
101
+ // #3151 — off Linux, Claude stamps procStart as `ps -o lstart=` in UTC ("Sun Oct 25 13:01:19 2026").
102
+ const PS_LSTART_TIMEOUT_MS = 5_000;
103
+ const psLstartArgv = (pid: number): string[] => ["ps", "-o", "lstart=", "-p", String(pid)];
104
+ const psLstartEnv = (): Record<string, string | undefined> => ({ ...process.env, TZ: "UTC", LC_ALL: "C" });
105
+
106
+ /** Whitespace-normalized start-time token; `ps` pads lstart, and pads a one-digit day. */
107
+ function procStartToken(raw: string | null | undefined): string | null {
108
+ const token = raw?.replace(/\s+/g, " ").trim();
109
+ return token || null;
110
+ }
111
+
112
+ export async function readPsLstartUtcAsync(pid: number): Promise<string | null> {
113
+ try {
114
+ const child = Bun.spawn(psLstartArgv(pid), { env: psLstartEnv(), stdout: "pipe", stderr: "ignore", timeout: PS_LSTART_TIMEOUT_MS, killSignal: "SIGKILL" });
115
+ const [stdout, exitCode] = await Promise.all([new Response(child.stdout).text(), child.exited]);
116
+ return exitCode === 0 ? procStartToken(stdout) : null;
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+
122
+ // Default liveness. On Linux `procStart` in the probe is `/proc/<pid>/stat` field 22
123
+ // (starttime, clock-ticks since boot) — verified equal live on CC 2.1.193; elsewhere it is UTC `ps` lstart.
124
+ // NB: we deliberately do NOT gate on `updatedAt` freshness — Claude only rewrites
125
+ // the probe on a status *transition*, so a legitimately busy turn keeps a frozen
126
+ // `updatedAt` for its whole duration (verified: a 90s+ busy turn never rewrote it).
127
+ // A staleness threshold would force a long busy turn back to the pane-scrape and
128
+ // reintroduce the #766 idle-while-working bug; pid-liveness + procStart cover the
129
+ // SIGKILL-frozen and pid-reuse cases without that hazard. `available` is not
130
+ // time-gated, and neither is the busy verdict (#3026, see claudeProbeVerdict).
131
+ /** The off-loop probe liveness check for `platform`: `/proc` on Linux, UTC `ps` lstart elsewhere. */
132
+ export function claudeProbeLivenessAsync(
133
+ platform: string = process.platform,
134
+ lstart: (pid: number) => Promise<string | null> = readPsLstartUtcAsync,
135
+ ): ClaudeProbeLivenessAsync {
136
+ return { procStartOf: platform === "linux" ? readProcStartTimeAsync : lstart, pidAlive: isPidAlive };
137
+ }
138
+
139
+ const DEFAULT_LIVENESS_ASYNC = claudeProbeLivenessAsync();
140
+
141
+ /** Whether a probe's recorded procStart names the process that owns `pid` now. */
142
+ function procStartMatches(recorded: string, live: string | null): boolean {
143
+ return live !== null && procStartToken(recorded) === procStartToken(live);
144
+ }
145
+
146
+ /**
147
+ * Stable process-incarnation token for a pane PID. A session probe's own procStart only
148
+ * proves that probe agrees with whichever process owns the number *now*; monitorless watches
149
+ * must instead pin the pane incarnation they started with so PID reuse cannot cross sessions.
150
+ */
151
+ export async function claudeProcessIncarnationAsync(pid: number): Promise<string | undefined> {
152
+ return (await DEFAULT_LIVENESS_ASYNC.procStartOf(pid)) ?? undefined;
153
+ }
154
+
155
+ /** The per-session probe path the runner reads directly (keyed by claude's pid). */
156
+ export function claudeSessionProbePath(configHome: string, pid: number): string {
157
+ return join(configHome, "sessions", `${pid}.json`);
158
+ }
159
+
160
+ /** Dotted-version compare; true iff `version` >= `min`. Missing/non-numeric → gate closed. */
161
+ export function claudeVersionAtLeast(version: string | null | undefined, min: string): boolean {
162
+ if (!version) return false;
163
+ const actual = version.split(".");
164
+ const wanted = min.split(".");
165
+ for (let i = 0; i < wanted.length; i++) {
166
+ const x = Number.parseInt(actual[i] ?? "0", 10);
167
+ const y = Number.parseInt(wanted[i] ?? "0", 10);
168
+ if (Number.isNaN(x)) return false; // unparseable build → fall back, don't trust the probe
169
+ if (x !== y) return x > y;
170
+ }
171
+ return true;
172
+ }
173
+
174
+ function normalizeStatus(value: unknown): ClaudeProbeStatus {
175
+ switch (value) {
176
+ case "busy":
177
+ case "shell":
178
+ case "idle":
179
+ case "waiting":
180
+ return value;
181
+ default:
182
+ return "unknown";
183
+ }
184
+ }
185
+
186
+ /** Pure: parse a probe JSON body + version-gate it. Malformed / old / unknown status → unavailable. */
187
+ export function parseClaudeSessionProbe(raw: string, minVersion = CLAUDE_PROBE_MIN_VERSION): ClaudeSessionReading {
188
+ let parsed: unknown;
189
+ try {
190
+ parsed = JSON.parse(raw);
191
+ } catch {
192
+ return UNAVAILABLE;
193
+ }
194
+ if (!parsed || typeof parsed !== "object") return UNAVAILABLE;
195
+ const probe = parsed as Record<string, unknown>;
196
+ const version = typeof probe.version === "string" ? probe.version : null;
197
+ const procStart = typeof probe.procStart === "string" ? probe.procStart : null;
198
+ const updatedAt = typeof probe.updatedAt === "number" ? probe.updatedAt : null;
199
+ const sessionId = typeof probe.sessionId === "string" && probe.sessionId.trim() ? probe.sessionId : null;
200
+ const cwd = typeof probe.cwd === "string" && probe.cwd.trim() ? probe.cwd : null;
201
+ if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt, sessionId, cwd };
202
+ const status = normalizeStatus(probe.status);
203
+ if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, sessionId, cwd, available: false };
204
+ const waitingFor = typeof probe.waitingFor === "string" && probe.waitingFor.trim() ? probe.waitingFor : null;
205
+ return { status, waitingFor, version, procStart, updatedAt, sessionId, cwd, available: true };
206
+ }
207
+
208
+ /**
209
+ * Read + parse the probe for `pid` under `configHome`. Missing file (older CC, a config home the
210
+ * runner can't resolve, or claude not yet started) → unavailable, so the caller falls back to the
211
+ * pane-scrape. Off-loop (#1838): every caller is a timer tick or the async `/exit` correlator.
212
+ */
213
+ export async function readClaudeSessionStatusAsync(
214
+ configHome: string,
215
+ pid: number,
216
+ opts: { minVersion?: string; liveness?: ClaudeProbeLivenessAsync } = {},
217
+ ): Promise<ClaudeSessionReading> {
218
+ let raw: string;
219
+ try {
220
+ raw = await readFile(claudeSessionProbePath(configHome, pid), "utf8");
221
+ } catch {
222
+ return UNAVAILABLE;
223
+ }
224
+ const reading = parseClaudeSessionProbe(raw, opts.minVersion ?? CLAUDE_PROBE_MIN_VERSION);
225
+ if (!reading.available) return reading;
226
+ // #769 P1-B: a frozen probe from a SIGKILLed claude (or a reused pid) would report a stale
227
+ // busy/shell forever. Verify the pid still belongs to the same process.
228
+ const live = opts.liveness ?? DEFAULT_LIVENESS_ASYNC;
229
+ if (reading.procStart) {
230
+ if (!procStartMatches(reading.procStart, await live.procStartOf(pid))) return { ...reading, status: "unknown", available: false };
231
+ } else if (!live.pidAlive(pid)) {
232
+ return { ...reading, status: "unknown", available: false };
233
+ }
234
+ return reading;
235
+ }
236
+
237
+ export interface ManagedClaudeProbeArgs {
238
+ pid?: number;
239
+ tmuxSession?: string;
240
+ tmuxSocket?: string;
241
+ configHomeEnv?: string;
242
+ }
243
+
244
+ /**
245
+ * The probe-lookup args for a tmux-launched Claude ManagedProcess. One home for the
246
+ * pid/session/socket/config-home tuple every caller of {@link readManagedClaudeStatusAsync}
247
+ * needs to assemble — the CLAUDE_CONFIG_DIR dig in particular was already being repeated.
248
+ */
249
+ export function claudeProbeArgsFor(process: ManagedProcess, tmuxSession: string, tmuxSocket?: string): ManagedClaudeProbeArgs {
250
+ const env = process.meta?.env as Record<string, string | undefined> | undefined;
251
+ return { pid: process.pid, tmuxSession, tmuxSocket, configHomeEnv: env?.CLAUDE_CONFIG_DIR };
252
+ }
253
+
254
+ /**
255
+ * #2261 — a clear-commit probe reading: Claude's own session UUID together with the pid it was
256
+ * read for. A committed `/clear` mints a NEW session, so a rotation of `sessionId` OBSERVED FOR
257
+ * THE SAME PID is machine-readable proof the loaded conversation is gone.
258
+ */
259
+ export interface ClaudeClearSessionReading { sessionId: string; pid: number }
260
+
261
+ /**
262
+ * Read one, or null for every "could not find out" outcome: no resolvable pid, no probe file, a
263
+ * version-gated-out or liveness-failed reading (`available === false`, which is what rules out a
264
+ * SIGKILL-frozen probe and a reused pid — #769 P1-B), or a probe carrying no sessionId at all.
265
+ * Deliberately total (never throws, never distinguishes flavours of absence): its only consumer
266
+ * treats anything short of a confirmed same-pid rotation as no evidence.
267
+ */
268
+ export async function readClaudeClearSessionAsync(args: ManagedClaudeProbeArgs): Promise<ClaudeClearSessionReading | null> {
269
+ try {
270
+ const managed = await readManagedClaudeStatusAsync(args);
271
+ if (!managed?.reading.available || !managed.reading.sessionId) return null;
272
+ return { sessionId: managed.reading.sessionId, pid: managed.pid };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * #2261 — bind a clear-commit reader to ONE pane pid, resolved up front.
280
+ *
281
+ * The barrier polls ~50 times inside its 10s budget. Re-resolving the pane pid per poll spawned a
282
+ * `tmux display-message` each time (the pid is undefined for a tmux launch), and — worse — let the
283
+ * pid CHANGE mid-wait: a restarted claude whose predecessor's `sessions/<pid>.json` still exists
284
+ * would surface a different sessionId and read as a rotation. Pinning the pid makes both go away;
285
+ * `undefined` (no resolvable pid) means no probe evidence is available at all.
286
+ */
287
+ export async function bindClaudeClearSessionReader(
288
+ process: ManagedProcess,
289
+ tmuxSession: string,
290
+ tmuxSocket?: string,
291
+ ): Promise<(() => Promise<ClaudeClearSessionReading | null>) | undefined> {
292
+ const pid = await resolveClaudePanePidAsync(process.pid, tmuxSession, tmuxSocket);
293
+ if (pid === undefined) return undefined;
294
+ const args = { ...claudeProbeArgsFor(process, tmuxSession, tmuxSocket), pid };
295
+ return () => readClaudeClearSessionAsync(args);
296
+ }
297
+
298
+ /** What the probe says about whether the `/clear` committed. `unknown` = no usable evidence. */
299
+ export type ClaudeClearSessionVerdict = "rotated" | "unchanged" | "unknown";
300
+
301
+ /**
302
+ * #2261 — compare a pre-clear reading against a current one. Both must be present AND describe the
303
+ * SAME pid; anything else is `unknown`, never a verdict. A `rotated` verdict is positive proof the
304
+ * clear committed; an `unchanged` one is positive proof it has NOT (yet) — the probe is
305
+ * authoritative in both directions whenever it is readable on both sides.
306
+ */
307
+ export function claudeClearSessionVerdict(
308
+ preClear: ClaudeClearSessionReading | null | undefined,
309
+ current: ClaudeClearSessionReading | null | undefined,
310
+ ): ClaudeClearSessionVerdict {
311
+ if (!preClear || !current || current.pid !== preClear.pid) return "unknown";
312
+ return current.sessionId === preClear.sessionId ? "unchanged" : "rotated";
313
+ }
314
+
315
+ /**
316
+ * Resolve the managed claude's pid (direct, or the tmux pane pid: the launcher `exec`s claude) and
317
+ * config home, then read its probe. undefined when the pid can't be resolved.
318
+ */
319
+ export async function readManagedClaudeStatusAsync(
320
+ args: ManagedClaudeProbeArgs,
321
+ liveness?: ClaudeProbeLivenessAsync,
322
+ ): Promise<{ reading: ClaudeSessionReading; pid: number; configHome: string } | undefined> {
323
+ const pid = await resolveClaudePanePidAsync(args.pid, args.tmuxSession, args.tmuxSocket);
324
+ if (pid === undefined) return undefined;
325
+ const configHome = claudeConfigHome(args.configHomeEnv);
326
+ return { reading: await readClaudeSessionStatusAsync(configHome, pid, { liveness }), pid, configHome };
327
+ }
328
+
329
+ /**
330
+ * The transcript JSONL path Claude Code writes for a session (#1235). Claude stores
331
+ * per-cwd transcripts at `<configHome>/projects/<cwd-slug>/<sessionId>.jsonl`, where
332
+ * the slug replaces every non-alphanumeric char in the absolute cwd with `-` (verified
333
+ * live on CC 2.1.205: `/home/u/p/.agent-relay/x` → `-home-u-p--agent-relay-x`). This is
334
+ * the hookless path the monitorless turn-watch feeds to the transcript reasoning tail so
335
+ * isolated/lean Claude workers (no Stop/UserPromptSubmit hooks) still mirror chat.
336
+ */
337
+ /** The config home a Claude process uses: its CLAUDE_CONFIG_DIR, the runner's, or the host default. */
338
+ export function claudeConfigHome(configHomeEnv?: string): string {
339
+ return configHomeEnv || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
340
+ }
341
+
342
+ export function claudeTranscriptPath(configHome: string, cwd: string, sessionId: string): string {
343
+ return join(configHome, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
344
+ }
345
+
346
+ /**
347
+ * #1642 — the transcript's last-modified time in epoch-ms, or undefined when it can't be read
348
+ * (absent/old probe with no sessionId+cwd, or the file not yet created). Claude appends to this
349
+ * JSONL on every turn event, so its mtime is the runner's hookless "events still flowing"
350
+ * heartbeat — the signal that keeps an actively-streaming turn's frozen busy probe from being
351
+ * aged out to the pane. A failed stat is silence, never a claim the turn ended: undefined leaves
352
+ * #1621 untouched.
353
+ *
354
+ * #1838 rows 14/15 — the only caller (the busy reconciler / turn watch, via activityFromProbeOrPane)
355
+ * is now off the event loop, so this is the sole variant and uses `fs/promises`. (The synchronous
356
+ * version was removed in the #1838 round-3 review: it had no production callers once the hot path
357
+ * went async — see the critical-loop ratchet note for claude-session-probe.ts.)
358
+ */
359
+ export async function claudeTranscriptMtimeMsAsync(
360
+ reading: Pick<ClaudeSessionReading, "sessionId" | "cwd"> | undefined,
361
+ configHome: string | undefined,
362
+ ): Promise<number | undefined> {
363
+ const sessionId = reading?.sessionId;
364
+ const cwd = reading?.cwd;
365
+ if (!sessionId || !cwd || !configHome) return undefined;
366
+ try {
367
+ return (await stat(claudeTranscriptPath(configHome, cwd, sessionId))).mtimeMs;
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Resolve the live transcript path for the managed claude via its session probe (#1235): the
375
+ * probe carries `sessionId` + `cwd`, and the runner knows the config home. undefined when the
376
+ * probe is absent/old or the pid can't be resolved (graceful degrade: no native capture).
377
+ */
378
+ export async function resolveManagedClaudeTranscriptPathAsync(args: {
379
+ pid?: number;
380
+ tmuxSession?: string;
381
+ tmuxSocket?: string;
382
+ configHomeEnv?: string;
383
+ }): Promise<string | undefined> {
384
+ const managed = await readManagedClaudeStatusAsync(args);
385
+ if (!managed) return undefined;
386
+ const { sessionId, cwd } = managed.reading;
387
+ if (!sessionId || !cwd) return undefined;
388
+ return claudeTranscriptPath(managed.configHome, cwd, sessionId);
389
+ }
390
+
391
+ /**
392
+ * Map the intrinsic probe status to the 3-valued activity. The whole point of #766:
393
+ * busy → busy — a turn is running, foreground tools included; the old
394
+ * pane-scrape misread it as idle and force-cleared.
395
+ * idle/shell → idle — `shell` is Claude's `idle` while a background local_bash task
396
+ * is alive (2.1.310: `status==="idle" && hasLiveLocalBash`).
397
+ * Relay's own monitor is one, so a managed session at rest
398
+ * reads `shell`, including right after an ESC (#2202).
399
+ * waiting → unknown — a permission/input dialog is a parked (not done) turn, so
400
+ * we neither assert compute-busy nor let the backstop clear
401
+ * the live turn. `waitingFor` is surfaced separately for
402
+ * permission/asking detection.
403
+ */
404
+ export function claudeProbeActivity(status: ClaudeProbeStatus): ClaudeProbeActivity {
405
+ switch (status) {
406
+ case "busy":
407
+ return "busy";
408
+ case "idle":
409
+ case "shell":
410
+ return "idle";
411
+ default:
412
+ return "unknown";
413
+ }
414
+ }
@@ -0,0 +1,190 @@
1
+ // #1647 — the Claude tmux shutdown sequence: observe, correlate, and only then
2
+ // (maybe) exit gracefully. Split out of ./claude at the #300 ratchet; the adapter
3
+ // keeps the watcher teardown and delegates the rest here.
4
+ //
5
+ // The policy itself lives in ./claude-exit. This module is the caller that samples
6
+ // live state and drives the loop.
7
+ import { managedTmuxSocketName, prepareTmuxPaneStop, prepareTmuxStop, tmuxHasSession, type PreparedTmuxStop } from "@humwick/sdk/tmux-utils";
8
+ import { tmuxOwnerIdFromEnv } from "../config";
9
+ import type { ManagedProcess, ShutdownOutcome } from "../adapter";
10
+ import { claudeProbeActivity, claudeProbeArgsFor, readManagedClaudeStatusAsync, type ClaudeProbeLivenessAsync } from "./claude-session-probe";
11
+ import { claudePaneIsBusy } from "./claude-status-detectors";
12
+ import { captureTmuxPaneAsync } from "./tmux-offload";
13
+ import { authorizeClaudeGracefulExit, claudeExitDenialIsPermanent, confirmClaudeExitDialogUnderGrant, mintClaudeShutdownIntent, submitClaudeExitCommand } from "./claude-exit";
14
+ import type { ClaudeExitGrant, ClaudeExitObservation, ClaudeTurnState } from "./claude-exit";
15
+
16
+ export function claudeShutdownGraceMs(timeoutMs: number): number {
17
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
18
+ return Math.min(timeoutMs, 10_000);
19
+ }
20
+
21
+ // The turn identity a shutdown intent is correlated against. Claude's intrinsic probe
22
+ // has no turn id, so the pair (sessionId, procStart) is the finest-grained identity
23
+ // available: it changes on any restart or session swap, which is exactly the
24
+ // "different process than the one you asked me to stop" case the token must catch.
25
+ export function claudeTurnKey(sessionId: string | null, procStart: string | null): string {
26
+ return `${sessionId ?? "?"}:${procStart ?? "?"}`;
27
+ }
28
+
29
+ /**
30
+ * Sample the facts a graceful exit must correlate against.
31
+ *
32
+ * Turn state is deliberately fail-closed: it reads "ended" ONLY on positive evidence
33
+ * from the intrinsic probe (Claude's own status file, which also carries the sessionId
34
+ * and procStart that bind this reading to this exact process). The pane scrape is kept
35
+ * as a VETO — it can turn "ended" into "live", never the reverse — because
36
+ * `claudePaneIsBusy` alone is exactly what false-negatived while the killed coordinator
37
+ * was mid-stream. No probe, no exit.
38
+ */
39
+ export async function observeClaudeExitState(
40
+ process: ManagedProcess,
41
+ sessionName: string,
42
+ socketName: string | undefined,
43
+ fallbackEpoch: number,
44
+ seams: { liveness?: ClaudeProbeLivenessAsync; capturePane?: typeof captureTmuxPaneAsync } = {},
45
+ ): Promise<ClaudeExitObservation> {
46
+ // #3152 — the shared off-loop reader, so a macOS `ps` lstart procStart binds this reading too.
47
+ const managed = await readManagedClaudeStatusAsync(claudeProbeArgsFor(process, sessionName, socketName), seams.liveness);
48
+ const reading = managed?.reading;
49
+ // #3186 — a managed Claude at rest reads `shell` (Relay's monitor is a live background task).
50
+ const probeIdle = reading?.available === true && claudeProbeActivity(reading.status) === "idle";
51
+ let turn: ClaudeTurnState = probeIdle ? "ended" : reading?.available === true ? "live" : "unknown";
52
+ if (turn === "ended") {
53
+ try {
54
+ if (claudePaneIsBusy(await (seams.capturePane ?? captureTmuxPaneAsync)(sessionName, socketName))) turn = "live";
55
+ } catch {
56
+ turn = "unknown"; // A pane we cannot read is not a pane we may type into.
57
+ }
58
+ }
59
+ return {
60
+ epoch: (process.meta?.providerEpoch as number | undefined) ?? fallbackEpoch,
61
+ ...(process.pid === undefined ? {} : { pid: process.pid }),
62
+ procStart: reading?.procStart ?? null,
63
+ sessionName,
64
+ providerSessionId: reading?.sessionId ?? null,
65
+ turn,
66
+ turnKey: claudeTurnKey(reading?.sessionId ?? null, reading?.procStart ?? null),
67
+ now: Date.now(),
68
+ };
69
+ }
70
+
71
+ // Injectable seams so the shutdown loop (which otherwise only talks to a live tmux
72
+ // server) is unit-testable — same pattern as ClaudeClearCommitDeps.
73
+ export interface ClaudeShutdownDeps {
74
+ hasSession?: (sessionName: string, socketName?: string) => boolean;
75
+ observe?: (process: ManagedProcess, sessionName: string, socketName?: string) => ClaudeExitObservation | Promise<ClaudeExitObservation>;
76
+ submitExit?: (grant: ClaudeExitGrant) => Promise<unknown>;
77
+ confirmExit?: (grant: ClaudeExitGrant) => unknown;
78
+ // #1746 — the reap now returns a terminal outcome (it verifies death, it does not merely
79
+ // fire a kill-session). Tests that only care that the reap ran may still return void.
80
+ reap?: (sessionName: string, socketName?: string) => ShutdownOutcome | void | Promise<ShutdownOutcome | void>;
81
+ sleep?: (ms: number) => Promise<unknown>;
82
+ prepareStop?: (sessionName: string, socketName?: string) => Promise<PreparedTmuxStop>;
83
+ preparePaneStop?: typeof prepareTmuxPaneStop;
84
+ }
85
+
86
+ export async function shutdownClaudeTmux(
87
+ process: ManagedProcess,
88
+ sessionName: string,
89
+ opts: { graceful: boolean; timeoutMs: number },
90
+ socketName: string | undefined,
91
+ fallbackEpoch: number,
92
+ deps: ClaudeShutdownDeps = {},
93
+ ): Promise<ShutdownOutcome> {
94
+ const hasSession = deps.hasSession ?? tmuxHasSession;
95
+ const observe = deps.observe ?? ((p, s, k) => observeClaudeExitState(p, s, k, fallbackEpoch));
96
+ const submitExit = deps.submitExit ?? submitClaudeExitCommand;
97
+ const confirmExit = deps.confirmExit ?? confirmClaudeExitDialogUnderGrant;
98
+ const sleep = deps.sleep ?? Bun.sleep;
99
+ const stop = deps.reap
100
+ ? async () => (await deps.reap!(sessionName, socketName)) ?? "killed"
101
+ : await prepareClaudeStop(sessionName, socketName, deps);
102
+
103
+ if (opts.graceful && hasSession(sessionName, socketName)) {
104
+ // The audited lifecycle request, pinned to the process it was issued against.
105
+ // Everything below is gated on it; nothing here can be reached from a detector.
106
+ const observed = await observe(process, sessionName, socketName);
107
+ const intent = mintClaudeShutdownIntent({
108
+ epoch: observed.epoch,
109
+ ...(observed.pid === undefined ? {} : { pid: observed.pid }),
110
+ procStart: observed.procStart,
111
+ sessionName,
112
+ providerSessionId: observed.providerSessionId,
113
+ turnKey: observed.turnKey,
114
+ });
115
+
116
+ let grant: ClaudeExitGrant | undefined;
117
+ const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
118
+ while (Date.now() < deadline) {
119
+ // Session disappearance still requires verification of the captured descendants.
120
+ if (!hasSession(sessionName, socketName)) return stop();
121
+ if (!grant) {
122
+ // #1647 — the guard that would have saved the coordinator. A turn that is live
123
+ // (or that we cannot positively confirm has ended) authorizes nothing, so we
124
+ // keep waiting and let the out-of-band reap below terminate the process if the
125
+ // turn never settles.
126
+ const decision = authorizeClaudeGracefulExit(intent, await observe(process, sessionName, socketName), socketName);
127
+ if (decision.authorized) {
128
+ grant = decision.grant;
129
+ await submitExit(grant);
130
+ } else if (claudeExitDenialIsPermanent(decision.reason)) {
131
+ // Nothing this loop can observe later will change the verdict — waiting out
132
+ // the grace window would only delay the reap. Terminate out of band now.
133
+ break;
134
+ }
135
+ } else {
136
+ // `/exit` while a background task is running raises Claude's "Exit anyway /
137
+ // Move to background / Stay" confirmation (#1514). Answering it is allowed ONLY
138
+ // through the same grant that submitted that `/exit`, and only for a dialog that
139
+ // was not already on screen beforehand.
140
+ confirmExit(grant);
141
+ }
142
+ await sleep(200);
143
+ }
144
+ }
145
+
146
+ // The out-of-band backstop, and the path #1647 prefers: it terminates the process
147
+ // without typing anything into the pane, so it is always reached when the guard above
148
+ // declines to authorize a graceful exit. #1746 — this is now a VERIFIED, escalating
149
+ // force-kill that returns a terminal outcome, not a fire-and-forget kill-session.
150
+ return stop();
151
+ }
152
+
153
+ async function prepareClaudeStop(sessionName: string, socketName: string | undefined, deps: ClaudeShutdownDeps): Promise<() => Promise<ShutdownOutcome>> {
154
+ const prepare = () => (deps.prepareStop ?? ((name, socket) => prepareTmuxStop({ name, socket,
155
+ onFailure: (failure) => console.error("[claude] Managed tmux stop failed", JSON.stringify({
156
+ at: new Date().toISOString(), name, socket, ...failure,
157
+ })),
158
+ })))(sessionName, socketName);
159
+ const preparePane = async () => socketName === managedTmuxSocketName(sessionName)
160
+ ? (deps.preparePaneStop ?? prepareTmuxPaneStop)({ name: sessionName, socket: socketName, owner: tmuxOwnerIdFromEnv() }).catch(() => undefined)
161
+ : undefined;
162
+ let fallback = await preparePane();
163
+ const stop = await prepare().catch(() => undefined);
164
+ const capturedGone = () => stop?.verifyCaptured?.() ?? Promise.resolve(true);
165
+ const paneGone = () => fallback?.gone?.() ?? Promise.resolve(true);
166
+ return async () => {
167
+ if (stop && (!fallback || await fallback.matches() || await fallback.gone?.())
168
+ && await stop() && await capturedGone() && await paneGone()) return "killed";
169
+ if (!stop || stop.captureComplete === false) {
170
+ fallback ??= await preparePane();
171
+ // Fence both sides of the retry: a replacement cannot borrow the failed capture.
172
+ if (fallback && await fallback.matches()) {
173
+ const retry = await prepare().catch(() => undefined);
174
+ if (retry && await fallback.matches() && await retry() && await capturedGone() && await paneGone()) return "killed";
175
+ }
176
+ }
177
+ if (fallback && await fallback.stop() && (!stop || stop.captureComplete === false || await stop())
178
+ && await capturedGone() && await paneGone()) return "killed";
179
+ return "failed-to-kill";
180
+ };
181
+ }
182
+
183
+ /** Shared portable teardown verifies the pane, descendants, and private tmux server. */
184
+ export async function terminateClaudeTmux(
185
+ sessionName: string,
186
+ socketName: string | undefined,
187
+ deps: ClaudeShutdownDeps = {},
188
+ ): Promise<ShutdownOutcome> {
189
+ return (await prepareClaudeStop(sessionName, socketName, deps))();
190
+ }