@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,282 @@
1
+ import { errMessage } from "@humwick/sdk";
2
+ import type { PaneBusyReading } from "./adapter";
3
+
4
+ type ProviderActivity = "busy" | "idle" | "unknown";
5
+
6
+ const BUSY_RECONCILE_POLL_MS = 4_000;
7
+ const BUSY_RECONCILE_IDLE_CONFIRM = 8;
8
+ const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
9
+ const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
10
+ const PROVIDER_TURN_CLEAR_TIMEOUT_MS = 60_000;
11
+ // #1980 U2 — how many consecutive reconcile ticks a live turn may be HELD (never force-cleared,
12
+ // because there is no positive end-evidence) before the backstop escalates a NOTIFY-ONLY
13
+ // watchdog. At the 4s poll this is ~20 min. The escalation NEVER cancels the turn (a 44-min gate
14
+ // is legitimate); it only surfaces "this turn has been held a long time" to the coordinator, so a
15
+ // genuinely wedged-but-active agent (a frozen `busy`, or a pane-scrape-only long tool the runner
16
+ // cannot prove idle) is seen instead of silently cancelled. One-shot per turn (reset on arm/disarm).
17
+ //
18
+ // #2010 INVARIANT: this interval (BUSY_RECONCILE_WATCHDOG_TICKS * BUSY_RECONCILE_POLL_MS, ~20
19
+ // min) must stay >= the server's STUCK_BUSY_PROVIDER_TURN_MS threshold (src/config.ts, default
20
+ // 15 min but operator-configurable via AGENT_RELAY_STUCK_BUSY_PROVIDER_TURN_MS). The server-side
21
+ // gate in auditProviderTurnWatchdog (src/services/turn-reconcile-audit.ts) only wakes the
22
+ // coordinator once staleForMs (time since real last progress) reaches that threshold. If an
23
+ // operator raises the server threshold ABOVE this interval, a genuinely dead-from-the-start turn
24
+ // could reach the watchdog tick count while staleForMs is still under the (raised) threshold —
25
+ // the gate would then suppress a real stall. Keep this interval the larger of the two, or the
26
+ // suppression re-enters.
27
+ export const BUSY_RECONCILE_WATCHDOG_TICKS = 300;
28
+
29
+ interface BusyReconcilerDeps {
30
+ isStopped(): boolean;
31
+ hasProcess(): boolean;
32
+ currentStatus(): "idle" | "busy" | "offline" | "error";
33
+ isProviderBlocked(): boolean;
34
+ hasProviderTurn(): boolean;
35
+ canProbeActivity(): boolean;
36
+ probeActivity(): Promise<ProviderActivity> | undefined;
37
+ // #769 P1-A / #1656: optional independent pane-busy read. `busy` vetoes a clear; `not-busy`
38
+ // is the positive corroboration every counted idle tick requires; anything else is unknown
39
+ // and holds the turn. Omitted entirely by providers with no pane to read.
40
+ probePaneBusy?(): Promise<PaneBusyReading> | undefined;
41
+ clearProviderTurn(reason: string): void;
42
+ // #1980 U2 — escalate a NOTIFY-ONLY watchdog for a live turn the backstop has HELD past the
43
+ // prolonged-hold threshold (a frozen-active `busy`, or a pane-scrape-only long tool that can
44
+ // never be positively proven idle — probeActivity reads `unknown`, not `idle`, so the clear
45
+ // paths are never reached). The runner turns this into a coordinator notification; it MUST NOT
46
+ // cancel the turn. Optional: providers/tests without a watchdog simply hold silently.
47
+ escalateWatchdog?(reason: string): void;
48
+ /** Every no-provider-turn edge (#2984: a pending effort change applies here). */
49
+ turnCleared?(): void;
50
+ sessionDebug(message: string): void;
51
+ }
52
+
53
+ export class BusyReconciler {
54
+ private idleStreak = 0;
55
+ private timer?: ReturnType<typeof setInterval>;
56
+ private sawBusy = false;
57
+ // #1980 U2 — consecutive reconcile ticks this live turn has been held without ending, and the
58
+ // one-shot guard so the prolonged-hold watchdog notifies at most once per turn. Both reset on
59
+ // arm/disarm (a new turn starts a fresh hold budget).
60
+ private heldTicks = 0;
61
+ private watchdogEscalated = false;
62
+ // #1838 row 14 — the activity probe now runs OFF the runner's event loop (async tmux
63
+ // subprocess + probe reads), so a single reconcile can outlast the 4s poll interval when a
64
+ // tmux socket is slow. This guard drops any timer tick that fires while the previous
65
+ // reconcile is still in flight, so slow probes can never stack N concurrent reads on one
66
+ // pane. It is only consulted on the TIMER path (runInBackground); tests that drive run()
67
+ // directly and await it to completion are unaffected — the flag is always clear by the next
68
+ // call.
69
+ private reconcileInFlight = false;
70
+ // #1838 round-3 HIGH — a monotonic generation, the same await-fence the turn watcher uses.
71
+ // Captured at the start of a reconcile and re-checked after EVERY await before any streak
72
+ // mutation or clearProviderTurn: a disarm / re-arm / turn clear that happens while an off-loop
73
+ // probe is in flight means the verdict describes a STALE turn, so it must not clear a
74
+ // successor. Bumped on arm, disarm, and every turn-clear signal (providerTurnCleared) — the
75
+ // three points at which the turn identity this reconciler is judging can change.
76
+ private generation = 0;
77
+ private readonly providerTurnClearWaiters = new Set<(error?: Error) => void>();
78
+
79
+ constructor(private readonly deps: BusyReconcilerDeps) {}
80
+
81
+ arm(): void {
82
+ if (this.timer || !this.deps.canProbeActivity()) return;
83
+ this.generation++;
84
+ this.idleStreak = 0;
85
+ this.sawBusy = false;
86
+ this.heldTicks = 0;
87
+ this.watchdogEscalated = false;
88
+ this.timer = setInterval(() => this.runInBackground(), BUSY_RECONCILE_POLL_MS);
89
+ }
90
+
91
+ disarm(): void {
92
+ this.generation++;
93
+ if (this.timer) clearInterval(this.timer);
94
+ this.timer = undefined;
95
+ this.idleStreak = 0;
96
+ this.sawBusy = false;
97
+ this.heldTicks = 0;
98
+ this.watchdogEscalated = false;
99
+ }
100
+
101
+ // #1838 round-3 HIGH — true when an in-flight reconcile that started at `gen` must abandon
102
+ // without mutating streak state or clearing: the generation moved (arm/disarm/turn clear), or
103
+ // the turn/process/lifecycle preconditions it verified BEFORE its await no longer hold. Cheap,
104
+ // synchronous, re-evaluated after each await.
105
+ private stale(gen: number): boolean {
106
+ return gen !== this.generation
107
+ || this.deps.isStopped()
108
+ || !this.deps.hasProcess()
109
+ || this.deps.currentStatus() !== "busy"
110
+ || this.deps.isProviderBlocked()
111
+ || !this.deps.hasProviderTurn();
112
+ }
113
+
114
+ scheduleInterruptReconcile(): void {
115
+ setTimeout(() => {
116
+ if (this.deps.isStopped() || !this.deps.hasProcess()) return;
117
+ this.reconcileInterruptInBackground();
118
+ }, INTERRUPT_RECONCILE_DELAY_MS);
119
+ }
120
+
121
+ // These two calls run from timers, so their promises have no caller that can observe a
122
+ // rejection. In particular clearProviderTurn can synchronously throw while publishing its
123
+ // durable timeline event during teardown. Catch at this boundary so an expected race cannot
124
+ // become Bun's process-fatal unhandled rejection; retain the error in the session log.
125
+ private runInBackground(): void {
126
+ // #1838 row 14 — skip this tick if the previous off-loop reconcile is still running.
127
+ if (this.reconcileInFlight) {
128
+ this.deps.sessionDebug("busy reconcile skipped: previous probe still in flight");
129
+ return;
130
+ }
131
+ this.reconcileInFlight = true;
132
+ void this.run()
133
+ .catch((error) => this.reportBackgroundRejection("busy reconcile", error))
134
+ .finally(() => { this.reconcileInFlight = false; });
135
+ }
136
+
137
+ private reconcileInterruptInBackground(): void {
138
+ void this.reconcileInterrupt().catch((error) => this.reportBackgroundRejection("post-interrupt reconcile", error));
139
+ }
140
+
141
+ private reportBackgroundRejection(scope: string, error: unknown): void {
142
+ // Logging must not rethrow from the rejection handler and recreate the same unhandled path.
143
+ try { this.deps.sessionDebug(`${scope} failed: ${errMessage(error)}`); } catch {}
144
+ }
145
+
146
+ private async reconcileInterrupt(): Promise<void> {
147
+ const gen = this.generation;
148
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) return;
149
+ const probe = this.deps.probeActivity();
150
+ let activity: ProviderActivity = "unknown";
151
+ try { if (probe) activity = await probe; } catch { return; }
152
+ // #1838 round-3 HIGH: re-check after the probe await. Turn A was interrupted, its probe
153
+ // stalled, A ended and Turn B began — A's stale `idle` must NOT clear B. `stale(gen)` catches
154
+ // the disarm/re-arm/turn-clear (via generation) AND a status/turn that moved out from under us.
155
+ if (this.stale(gen)) return;
156
+ this.deps.sessionDebug(`post-interrupt reconcile probe=${activity}`);
157
+ if (activity === "idle") this.deps.clearProviderTurn("post-interrupt");
158
+ }
159
+
160
+ /** Await the same provider-turn-clear signal maintained by status events and the
161
+ * interrupt backstop. Redirect uses this boundary before starting its next turn. */
162
+ waitForProviderTurnClear(timeoutMs = PROVIDER_TURN_CLEAR_TIMEOUT_MS): Promise<void> {
163
+ if (!this.deps.hasProviderTurn()) return Promise.resolve();
164
+ return new Promise<void>((resolve, reject) => {
165
+ let timeout: ReturnType<typeof setTimeout>;
166
+ const finish = (error?: Error) => {
167
+ clearTimeout(timeout);
168
+ this.providerTurnClearWaiters.delete(finish);
169
+ if (error) reject(error); else resolve();
170
+ };
171
+ timeout = setTimeout(() => finish(new Error("timed out waiting for interrupted provider turn to clear")), timeoutMs);
172
+ timeout.unref?.();
173
+ this.providerTurnClearWaiters.add(finish);
174
+ });
175
+ }
176
+
177
+ providerTurnCleared(): void {
178
+ // #1838 round-3 HIGH — a turn clear/replacement invalidates any in-flight reconcile that was
179
+ // judging the turn that just ended. Bump BEFORE the waiter-fan-out and the hasProviderTurn
180
+ // guard: this method is only invoked by the runner on a no-provider-turn edge, so it never
181
+ // fires during a legitimate active reconcile.
182
+ this.generation++;
183
+ if (this.deps.hasProviderTurn()) return;
184
+ for (const finish of [...this.providerTurnClearWaiters]) finish();
185
+ this.deps.turnCleared?.();
186
+ }
187
+
188
+ private async run(): Promise<void> {
189
+ const gen = this.generation;
190
+ const probe = this.deps.probeActivity();
191
+ if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
192
+ this.disarm();
193
+ return;
194
+ }
195
+ if (this.deps.currentStatus() !== "busy" || this.deps.isProviderBlocked()) {
196
+ this.idleStreak = 0;
197
+ return;
198
+ }
199
+ if (!this.deps.hasProviderTurn()) {
200
+ this.disarm();
201
+ return;
202
+ }
203
+ // #1656: a probe we could not read is `unknown`, not `idle`. It breaks the streak
204
+ // exactly like any other non-idle reading — it must never be silently skipped past.
205
+ let activity: ProviderActivity;
206
+ try { activity = await probe; } catch { activity = "unknown"; }
207
+ // #1838 round-3 HIGH: re-check after the (off-loop) probe await, BEFORE touching sawBusy /
208
+ // idleStreak. A disarm/re-arm or turn clear while the probe was in flight means this verdict
209
+ // is for a stale turn — abandon rather than mutate the successor's streak.
210
+ if (this.stale(gen)) return;
211
+ // #1980 U2 — this tick observed a still-live, still-busy provider turn that did not end. A
212
+ // turn that is genuinely wedged-but-active never reaches a force-clear at all: a frozen
213
+ // foreground tool keeps reading `busy`, and a pane-scrape-only long tool reads `unknown` (not
214
+ // `idle`, so its streak never accumulates). Count the hold so such a turn is escalated to a
215
+ // notify-only watchdog rather than being either silently cancelled OR held forever in silence.
216
+ this.heldTicks += 1;
217
+ if (this.heldTicks >= BUSY_RECONCILE_WATCHDOG_TICKS) this.emitWatchdog("prolonged-hold");
218
+ if (activity === "busy") this.sawBusy = true;
219
+ if (activity !== "idle") {
220
+ this.idleStreak = 0;
221
+ this.deps.sessionDebug(`reconcile probe=${activity} sawBusy=${this.sawBusy} streak=${this.idleStreak}`);
222
+ return;
223
+ }
224
+ // #769 P1-A / #1656: the probe alone does not get to end a turn. An independent pane read
225
+ // runs on EVERY idle tick — not just once at the force-clear moment — and the streak counts
226
+ // only ticks where it positively reported `not-busy`. That is the substance of #1656: the
227
+ // old counter measured "how long since I last saw busy", which a pane that cannot be read at
228
+ // all satisfies perfectly. It now measures accumulated positive evidence that the turn ended,
229
+ // and any tick that fails to produce such evidence resets it to zero.
230
+ const pane = await this.readPaneBusy();
231
+ // #1838 round-3 HIGH: re-check after the pane await too — this is the tick that can call
232
+ // clearProviderTurn, so a turn that was replaced during the pane read must not be cleared.
233
+ if (this.stale(gen)) return;
234
+ if (pane === "busy") {
235
+ this.idleStreak = 0;
236
+ this.sawBusy = true;
237
+ this.deps.sessionDebug("reconcile probe=idle vetoed: pane still reads busy");
238
+ return;
239
+ }
240
+ if (pane === "no-session" || pane === "unreadable") {
241
+ this.idleStreak = 0;
242
+ this.deps.sessionDebug(`reconcile probe=idle but pane=${pane} — unknown, holding the turn`);
243
+ return;
244
+ }
245
+ this.idleStreak += 1;
246
+ const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
247
+ this.deps.sessionDebug(`reconcile not-busy pane=${pane ?? "none"} sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
248
+ if (this.idleStreak < confirm) return;
249
+ // #1980 U2 — reaching here means POSITIVE end-evidence: the activity read is `idle`, which for
250
+ // a probe-capable Claude session comes only from the provider's own idle assertion or a
251
+ // busy-verdict a real interrupt / trust-window decay turned idle — NOT from pane silence. A
252
+ // long foreground tool the runner cannot see now reads `unknown` (probeActivity, #1980 U2),
253
+ // whose streak resets above, so the `no-busy-observed` force-cancel that lost the 44-min gate
254
+ // is no longer reachable from silence. Genuine ends still clear here, with their reason +
255
+ // notification (the interrupted-turn audit downstream).
256
+ // Capture the reason BEFORE disarm(): disarm resets `sawBusy`, so reading it afterwards (as the
257
+ // original did) always reported `no-busy-observed`, mislabeling even a genuine sawBusy clear —
258
+ // the confirm threshold above already branched correctly on the pre-disarm value.
259
+ const reason = this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)";
260
+ this.disarm();
261
+ this.deps.clearProviderTurn(reason);
262
+ }
263
+
264
+ // #1980 U2 — fire the notify-only watchdog at most once per turn. The reconciler NEVER cancels
265
+ // the turn on this path; it only asks the runner to tell the coordinator the turn is being held.
266
+ private emitWatchdog(reason: string): void {
267
+ if (this.watchdogEscalated) return;
268
+ this.watchdogEscalated = true;
269
+ this.deps.sessionDebug(`busy backstop holding live turn past threshold; escalating watchdog (${reason})`);
270
+ try { this.deps.escalateWatchdog?.(reason); } catch { /* a watchdog failure must never break reconcile */ }
271
+ }
272
+
273
+ /** The pane half of one tick. `undefined` means the provider wired no pane read at all (no
274
+ * pane exists to consult) — distinct from a pane that exists and could not be read, which is
275
+ * `unreadable` and blocks. A dep that rejects is `unreadable` for the same reason: a failed
276
+ * read is a fact about the read, never about the provider. */
277
+ private async readPaneBusy(): Promise<PaneBusyReading | undefined> {
278
+ const pending = this.deps.probePaneBusy?.();
279
+ if (!pending) return undefined;
280
+ try { return await pending; } catch { return "unreadable"; }
281
+ }
282
+ }
@@ -0,0 +1,140 @@
1
+ type ClaimKind = "message" | "task" | "command";
2
+ type RunnerWorkKind = "provider-turn" | "subagent" | "background-script";
3
+ type RunnerBusyReason = RunnerWorkKind | ClaimKind;
4
+ type RunnerSemanticStatus = "idle" | "busy" | "offline" | "error";
5
+
6
+ interface ClaimRecord {
7
+ id: string;
8
+ kind: ClaimKind;
9
+ startedAt: number;
10
+ expiresAt?: number;
11
+ }
12
+
13
+ interface WorkRecord {
14
+ id: string;
15
+ kind: RunnerWorkKind;
16
+ label?: string;
17
+ role?: string;
18
+ parentId?: string;
19
+ metadata?: Record<string, unknown>;
20
+ // OS pids of the underlying process(es) this work tracks (#650). When present,
21
+ // a periodic liveness reconcile clears the record once every pid is dead — so a
22
+ // tracked process killed out-of-band (no exit event) can't strand the agent busy.
23
+ pids?: number[];
24
+ startedAt: number;
25
+ }
26
+
27
+ export class ClaimTracker {
28
+ private status: RunnerSemanticStatus = "idle";
29
+ private readonly claims = new Map<string, ClaimRecord>();
30
+ private readonly work = new Map<string, WorkRecord>();
31
+
32
+ setProviderBusy(busy: boolean): boolean {
33
+ return busy ? this.startWork("provider-turn", "provider-turn") : this.finishWork("provider-turn", "provider-turn");
34
+ }
35
+
36
+ startWork(kind: RunnerWorkKind, id: string = kind, details: Omit<Partial<WorkRecord>, "id" | "kind" | "startedAt"> = {}): boolean {
37
+ const before = this.currentStatus();
38
+ const key = `${kind}:${id}`;
39
+ const existing = this.work.get(key);
40
+ this.work.set(key, {
41
+ id,
42
+ kind,
43
+ label: details.label ?? existing?.label,
44
+ role: details.role ?? existing?.role,
45
+ parentId: details.parentId ?? existing?.parentId,
46
+ metadata: details.metadata ?? existing?.metadata,
47
+ pids: details.pids ?? existing?.pids,
48
+ startedAt: existing?.startedAt ?? Date.now(),
49
+ });
50
+ return before !== this.currentStatus();
51
+ }
52
+
53
+ finishWork(kind: RunnerWorkKind, id: string = kind): boolean {
54
+ const before = this.currentStatus();
55
+ this.work.delete(`${kind}:${id}`);
56
+ return before !== this.currentStatus();
57
+ }
58
+
59
+ clearWorkKind(kind: RunnerWorkKind): boolean {
60
+ const before = this.currentStatus();
61
+ for (const key of [...this.work.keys()]) {
62
+ if (key.startsWith(`${kind}:`)) this.work.delete(key);
63
+ }
64
+ return before !== this.currentStatus();
65
+ }
66
+
67
+ setTerminalStatus(status: "offline" | "error"): boolean {
68
+ const before = this.status;
69
+ this.status = status;
70
+ return before !== status;
71
+ }
72
+
73
+ clearTerminalStatus(): boolean {
74
+ const before = this.status;
75
+ this.status = "idle";
76
+ return before !== this.status;
77
+ }
78
+
79
+ startClaim(kind: ClaimKind, id: string, expiresAt?: number): boolean {
80
+ const before = this.currentStatus();
81
+ this.claims.set(`${kind}:${id}`, { kind, id, startedAt: Date.now(), expiresAt });
82
+ return before !== this.currentStatus();
83
+ }
84
+
85
+ finishClaim(kind: ClaimKind, id: string): boolean {
86
+ const before = this.currentStatus();
87
+ this.claims.delete(`${kind}:${id}`);
88
+ return before !== this.currentStatus();
89
+ }
90
+
91
+ expire(now = Date.now()): boolean {
92
+ const before = this.currentStatus();
93
+ for (const [key, claim] of this.claims) {
94
+ if (claim.expiresAt !== undefined && claim.expiresAt <= now) this.claims.delete(key);
95
+ }
96
+ return before !== this.currentStatus();
97
+ }
98
+
99
+ currentStatus(): RunnerSemanticStatus {
100
+ if (this.status === "offline" || this.status === "error") return this.status;
101
+ return this.work.size > 0 || this.claims.size > 0 ? "busy" : "idle";
102
+ }
103
+
104
+ reasons(): RunnerBusyReason[] {
105
+ const reasons = new Set<RunnerBusyReason>();
106
+ for (const item of this.work.values()) reasons.add(item.kind);
107
+ for (const claim of this.claims.values()) reasons.add(claim.kind);
108
+ return [...reasons];
109
+ }
110
+
111
+ activeWork(): WorkRecord[] {
112
+ return [...this.work.values()];
113
+ }
114
+
115
+ /** True iff any active work carries pids the liveness reconcile can check (#650). */
116
+ hasLivenessTrackedWork(): boolean {
117
+ for (const item of this.work.values()) {
118
+ if (item.pids && item.pids.length > 0) return true;
119
+ }
120
+ return false;
121
+ }
122
+
123
+ /**
124
+ * Reconcile pid-bearing work against actual process liveness (#650): drop any
125
+ * record whose tracked pids are ALL dead — its underlying process died out-of-band
126
+ * (killed/crashed/orphaned) with no exit event to clear the busy marker. Records
127
+ * with no pids are never touched (no liveness signal to act on). Returns the removed
128
+ * records so the caller can emit the matching completion/abort + republish status.
129
+ */
130
+ reconcileLiveness(isAlive: (pid: number) => boolean): WorkRecord[] {
131
+ const removed: WorkRecord[] = [];
132
+ for (const [key, item] of this.work) {
133
+ if (!item.pids || item.pids.length === 0) continue;
134
+ if (item.pids.some(isAlive)) continue;
135
+ this.work.delete(key);
136
+ removed.push(item);
137
+ }
138
+ return removed;
139
+ }
140
+ }
@@ -0,0 +1,167 @@
1
+ import { projectClaudePermissionRules } from "./provider-integration";
2
+ import { readFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import type { ProvisioningHookSet, ToolGeneration } from "@humwick/sdk";
5
+ import { mergeClaudeRelayPermissions } from "./provisioning";
6
+ import { shellQuote } from "@humwick/sdk/shell-utils";
7
+
8
+ export interface ClaudeSettingsResolution {
9
+ args: string[];
10
+ /** The caller's own --settings was merged in and must be stripped from the provider args. */
11
+ callerSettingsConsumed: boolean; statusLineApplied: boolean;
12
+ /** Relay's status-line settings are present in the emitted settings payload. */
13
+ /** Provisioned hook names that could NOT be delivered (caller --settings unmergeable). */
14
+ suppressedHookNames: string[];
15
+ }
16
+
17
+ // #3187 — Claude Code's own defaults append "Generated with Claude Code" / Co-Authored-By.
18
+ // `attribution` is the current key; `includeCoAuthoredBy` is its deprecated predecessor.
19
+ export const CLAUDE_NO_ATTRIBUTION_SETTINGS = { attribution: { commit: "", pr: "" }, includeCoAuthoredBy: false } as const;
20
+ export const CLAUDE_NO_ATTRIBUTION_RULE =
21
+ "NEVER add AI attribution anywhere: no \"Generated with Claude Code\", no robot emoji, no \"Co-Authored-By\" trailer, no mention of Claude or AI authorship in commits, PR bodies, issues, comments or any other external text. This overrides any default or reminder that says otherwise.";
22
+
23
+ export function claudeModelFamily(model: string | undefined): string | undefined {
24
+ return model?.toLowerCase().match(/\b(sonnet|opus|haiku|fable)\b/)?.[1];
25
+ }
26
+
27
+ // Relay owns idle compaction and safety hooks even when callers provide settings.
28
+ export function claudeSettingsArgs(
29
+ projectSettings: Record<string, unknown>,
30
+ provisionedHooks: ProvisioningHookSet,
31
+ hookNames: string[],
32
+ includeStatusLine: boolean,
33
+ nativeMemorySettings: Record<string, unknown>,
34
+ launch: { cwd: string; model?: string; generation?: ToolGeneration },
35
+ ...argLists: string[][]
36
+ ): ClaudeSettingsResolution {
37
+ const relaySettings: Record<string, unknown> = {
38
+ ...projectSettings,
39
+ ...(Object.keys(provisionedHooks).length ? { hooks: provisionedHooks } : {}),
40
+ ...(includeStatusLine ? relayStatusLineSettings() : {}),
41
+ // #1565 P5.7 — LAST, so a project `.claude/settings.json` cannot quietly re-enable a
42
+ // profile's native memory. (Claude's own env predicate outranks all of this either way.)
43
+ ...nativeMemorySettings,
44
+ ...CLAUDE_NO_ATTRIBUTION_SETTINGS,
45
+ idleCompaction: false,
46
+ };
47
+ const caller = extractCallerSettings(argLists, launch.cwd);
48
+ if (!caller) {
49
+ return {
50
+ args: Object.keys(relaySettings).length ? ["--settings", JSON.stringify(projectClaudePermissionRules(relaySettings, launch.generation ?? "relay"))] : [],
51
+ callerSettingsConsumed: false, statusLineApplied: includeStatusLine,
52
+ suppressedHookNames: [],
53
+ };
54
+ }
55
+ if (caller.settings) {
56
+ const merged = mergeClaudeSettings(caller.settings, relaySettings, launch.model);
57
+ return { args: ["--settings", JSON.stringify(projectClaudePermissionRules(merged, launch.generation ?? "relay"))], callerSettingsConsumed: true, statusLineApplied: includeStatusLine && merged.statusLine === relaySettings.statusLine, suppressedHookNames: [] };
58
+ }
59
+ throw new Error("Claude --settings must be a JSON object or a readable JSON settings file (idleCompaction must be disabled)");
60
+ }
61
+
62
+ // The caller's `--settings` value, parsed as inline JSON when possible. `settings` is null for a
63
+ // file-path / malformed value (an inline object is required to merge relay settings in).
64
+ export function extractCallerSettings(argLists: string[][], cwd?: string): { settings: Record<string, unknown> | null } | null {
65
+ for (const list of argLists) {
66
+ for (let i = 0; i < list.length; i += 1) {
67
+ const arg = list[i];
68
+ if (arg === "--settings") return { settings: parseCallerSettings(list[i + 1], cwd) };
69
+ if (arg?.startsWith("--settings=")) return { settings: parseCallerSettings(arg.slice("--settings=".length), cwd) };
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function parseCallerSettings(value: string | undefined, cwd?: string): Record<string, unknown> | null {
76
+ if (!value) return null;
77
+ const trimmed = value.trim();
78
+ if (!trimmed.startsWith("{")) {
79
+ if (!cwd) return null;
80
+ try { return parseCallerSettings(readFileSync(resolve(cwd, trimmed), "utf8")); }
81
+ catch { throw new Error(`Cannot read Claude --settings file: ${trimmed}`); }
82
+ }
83
+ try {
84
+ const parsed = JSON.parse(trimmed);
85
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ // Merge relay settings into the caller's inline settings. The safety-critical `hooks` map is
92
+ // UNIONED per event (caller entries first, relay's baseline guard appended) so both fire; every
93
+ // other relay key is fill-if-absent (the caller's explicit value wins), preserving the historical
94
+ // "caller --settings wins" spirit for non-safety settings. Bypass acceptance follows the
95
+ // effective open approval mode: a caller's false value must not strand that launch in a modal.
96
+ function mergeClaudeSettings(caller: Record<string, unknown>, relay: Record<string, unknown>, mainModel: string | undefined): Record<string, unknown> {
97
+ const out: Record<string, unknown> = { ...caller };
98
+ for (const [key, value] of Object.entries(relay)) {
99
+ if (key === "modelSettings") {
100
+ const models = { ...(caller.modelSettings as Record<string, unknown> ?? {}) };
101
+ for (const [model, limits] of Object.entries(value as Record<string, unknown>)) {
102
+ const family = claudeModelFamily(model);
103
+ // Claude canonicalizes aliases and full IDs; enforce child limits without rewriting the main family.
104
+ if (family && family !== claudeModelFamily(mainModel)) for (const name of Object.keys(models)) {
105
+ if (claudeModelFamily(name) === family) models[name] = { ...(models[name] as Record<string, unknown>), ...(limits as Record<string, unknown>) };
106
+ }
107
+ models[model] = limits;
108
+ }
109
+ out.modelSettings = models;
110
+ }
111
+ else if (key === "claudeMdExcludes") out[key] = [...new Set([...(Array.isArray(caller[key]) ? caller[key] as unknown[] : []), ...(value as unknown[])])];
112
+ else if (key === "autoCompactEnabled" || key === "disableAllHooks") out[key] = value;
113
+ else if (key === "enabledPlugins") out[key] = { ...(caller[key] as Record<string, unknown> ?? {}), ...(value as Record<string, unknown>) };
114
+ else if (key === "hooks") out.hooks = mergeHookSets(caller.hooks, value);
115
+ else if (!(key in out) || key === "permissions" || key === "skipDangerousModePermissionPrompt" || key === "idleCompaction") out[key] = key === "permissions" ? mergeClaudeRelayPermissions(out, value).permissions : value;
116
+ }
117
+ return out;
118
+ }
119
+
120
+ export function mergeHookSets(caller: unknown, relay: unknown): ProvisioningHookSet {
121
+ const base: ProvisioningHookSet = isHookSet(caller) ? { ...caller } : {};
122
+ if (!isHookSet(relay)) return base;
123
+ for (const [event, entries] of Object.entries(relay)) {
124
+ base[event] = [...(base[event] ?? []), ...entries];
125
+ }
126
+ return base;
127
+ }
128
+
129
+ function isHookSet(value: unknown): value is ProvisioningHookSet {
130
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
131
+ }
132
+
133
+ // Strip every `--settings <json>` / `--settings=<json>` occurrence from a provider arg list. The
134
+ // adapter applies this to the caller args once the assembler has folded them into its own merged
135
+ // `--settings` (callerSettingsConsumed), so exactly one `--settings` reaches the launch (#1096).
136
+ export function stripSettingsArgs(args: string[]): string[] {
137
+ const result: string[] = [];
138
+ for (let i = 0; i < args.length; i += 1) {
139
+ const arg = args[i];
140
+ if (arg === "--settings") { i += 1; continue; }
141
+ if (arg?.startsWith("--settings=")) continue;
142
+ if (arg !== undefined) result.push(arg);
143
+ }
144
+ return result;
145
+ }
146
+
147
+ export function hasSettingsArg(args: string[]): boolean {
148
+ return args.some((arg) => arg === "--settings" || arg.startsWith("--settings="));
149
+ }
150
+
151
+ // Claude relay status-line + readable-thinking settings, injected only when no caller
152
+ // --settings is already present (caller wins). Relocated from claude.ts (#557).
153
+ export function sessionStatusLineSettingsArgs(...argLists: string[][]): string[] {
154
+ if (argLists.some(hasSettingsArg)) return [];
155
+ return ["--settings", JSON.stringify(relayStatusLineSettings())];
156
+ }
157
+
158
+ export function relayStatusLineSettings(): Record<string, unknown> {
159
+ return {
160
+ statusLine: { type: "command", command: `node ${shellQuote(claudeStatusLineProbePath())}`, refreshInterval: 30 },
161
+ showThinkingSummaries: true,
162
+ };
163
+ }
164
+
165
+ function claudeStatusLineProbePath(): string {
166
+ return resolve(import.meta.dir, "../plugins/claude/claude-statusline-probe.cjs");
167
+ }