@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,426 @@
1
+ import { extractClaudeModelUnavailableMessage } from "@humwick/sdk";
2
+ import type { PaneObservation, ProviderStatusUpdate } from "../adapter";
3
+ import type { ClaudeClearSessionVerdict, ClaudeProbeActivity } from "./claude-session-probe";
4
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
5
+
6
+ // LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
7
+ // Claude Code's intrinsic session probe (readClaudeSessionStatusAsync, ./claude-session-probe):
8
+ // `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
9
+ // on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
10
+ // probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
11
+ // (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
12
+ // CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
13
+ // restyles its footer/banner. Deliberately substring/regex based (no machine-readable
14
+ // signal existed when these were written). Known break conditions, so the next CC restyle
15
+ // is a fast fix rather than a hunt:
16
+ // readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
17
+ // "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
18
+ // the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
19
+ // busy (claudePaneIsBusy) breaks if CC drops BOTH the live "… (<elapsed>" spinner
20
+ // counter and the 2.1.218 "Thought for <elapsed> (ctrl+o to expand)" activity line
21
+ // (the "esc to interrupt" hint was already dropped in 2.1.x).
22
+ // FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
23
+ // transcript discussing "esc to interrupt", or this very comment shown in a pane)
24
+ // reads as ready. Busy checks gate themselves on the LAST N lines (the live
25
+ // footer), including direct callers such as the prompt-gate guard.
26
+ // History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
27
+ export function claudePaneLooksReady(text: string): boolean {
28
+ // A quoted dialog can remain above a LIVE composer. Its persistent footer is
29
+ // stronger evidence than modal text elsewhere in the visible pane.
30
+ if (text.includes("bypass permissions on") || text.includes("shift+tab to cycle")
31
+ || text.includes("? for shortcuts") || text.includes("/effort")) return true;
32
+ // The 2.1.282 bypass modal contains both "Claude Code" and "bypass permissions".
33
+ // Those generic readiness witnesses otherwise submit the initial prompt into the
34
+ // acceptance dialog instead of waiting for an input composer.
35
+ if (claudeBypassPermissionDialog(text)) return false;
36
+ // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
37
+ // conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
38
+ // a run) must detect readiness from the PERSISTENT input-box footer that Claude always
39
+ // renders at the prompt. Requiring the banner made every delivery after the first screen
40
+ // of output time out as "input not ready". Any one of these strong, Claude-specific
41
+ // markers means the TUI is up and the input box exists.
42
+ return text.includes("Welcome back")
43
+ || text.includes("Claude Code");
44
+ }
45
+
46
+ /** The visible 2.1.282 acceptance modal, before Claude has an input composer. */
47
+ export function claudeBypassPermissionDialog(text: string): boolean {
48
+ return /running in Bypass Permissions mode/i.test(text)
49
+ && /^\s*[❯>]?\s*No, exit\s*$/im.test(text)
50
+ && /^\s*[❯>]?\s*Yes, I accept\s*$/im.test(text);
51
+ }
52
+
53
+ /** Adapter-owned copy for the startup modal's display-only blocked state. */
54
+ export function claudeBypassDialogDisplayGate(sessionName: string, observedAt: number): NonNullable<PaneObservation["displayGate"]> {
55
+ return { phase: "show", timelineTitle: "Claude waiting at bypass permissions dialog", providerState: {
56
+ state: "blocked", reason: "permissionRequest", label: "Claude bypass acceptance dialog",
57
+ message: "Claude is waiting for Bypass Permissions acceptance in its terminal.",
58
+ source: `claude-pane:${sessionName}`,
59
+ recommendedAction: "Open the terminal and select Yes, I accept.", enteredAt: observedAt,
60
+ } };
61
+ }
62
+
63
+ // The live spinner: "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)", "… (", optional hook or
64
+ // tool status ("running PostToolUse hook · ", #3026), then "[Nh ][Nm ]Ns". Anchored on the
65
+ // gerund ellipsis so it can't match the "… +N lines (ctrl+o to expand)" truncation marker,
66
+ // the idle input box, or the "/btw … without interrupting Claude's current work" hint.
67
+ const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:[^()\n]*·\s*)?(?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
68
+ // Claude Code 2.1.218 replaced the gerund spinner during its thinking phase with
69
+ // "Thought for 13s (ctrl+o to expand)" (and, after a tool call starts,
70
+ // "Thought for 12s, called callmux (ctrl+o to expand)"). The intrinsic session
71
+ // probe remains the primary, version-robust activity source, but 2.1.218 can leave
72
+ // that probe idle during this phase. Treat this live footer as a busy veto so a
73
+ // monitorless worker cannot false-idle before its first assistant output.
74
+ const CLAUDE_BUSY_THOUGHT_RE = /^\s*Thought for (?:\d+h\s+)?(?:\d+m\s+)?\d+s(?:,\s*called [^(\n]+)?\s*\(ctrl\+o to expand\)\s*$/im;
75
+ const CLAUDE_ACTIVITY_FOOTER_LINES = 10;
76
+ /** A full-row rule; the composer's input rows sit between two of them. */
77
+ export const CLAUDE_COMPOSER_RULE_RE = /^\s*─{10,}\s*$/;
78
+ // Input rows plus the two or three status rows under the composer; a taller composer falls back.
79
+ const CLAUDE_COMPOSER_SEARCH_LINES = 30;
80
+ const CLAUDE_ACTIVITY_BLOCK_MAX_LINES = 40;
81
+ // A notification Claude right-aligns on the row above the composer ("You've used 80% of …").
82
+ const CLAUDE_RIGHT_ALIGNED_NOTICE_RE = /^\s{20,}\S/;
83
+
84
+ interface ClaudePaneRegions {
85
+ /** The composer and its status rows; without a recognizable composer, the last rows. */
86
+ footer: string;
87
+ /** The block right above the composer, where the live spinner or thinking row renders. */
88
+ activity?: string[];
89
+ }
90
+
91
+ // #2202 — find the composer instead of counting rows from the bottom. Inline rendering
92
+ // (CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN) pads the capture with blank rows below it, and a
93
+ // completed "Thought for …" row can sit within any fixed window above it.
94
+ function claudePaneRegions(text: string): ClaudePaneRegions {
95
+ const lines = text.trimEnd().split("\n");
96
+ const composer = claudeComposerRuleRow(lines);
97
+ if (composer === undefined) return { footer: lines.slice(-CLAUDE_ACTIVITY_FOOTER_LINES).join("\n") };
98
+ let row = composer - 1;
99
+ while (row >= 0 && (!lines[row]!.trim() || CLAUDE_RIGHT_ALIGNED_NOTICE_RE.test(lines[row]!))) row--;
100
+ const activity: string[] = [];
101
+ for (const floor = row - CLAUDE_ACTIVITY_BLOCK_MAX_LINES; row > floor && row >= 0 && lines[row]!.trim(); row--) activity.unshift(lines[row]!);
102
+ return { footer: lines.slice(composer).join("\n"), activity };
103
+ }
104
+
105
+ /** The rule row right above the composer's `❯` input row, searched near the bottom of the pane. */
106
+ function claudeComposerRuleRow(lines: string[]): number | undefined {
107
+ for (let i = lines.length - 1; i >= Math.max(1, lines.length - CLAUDE_COMPOSER_SEARCH_LINES); i--) {
108
+ if (lines[i]!.trimStart().startsWith("❯") && CLAUDE_COMPOSER_RULE_RE.test(lines[i - 1]!)) return i - 1;
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ export function claudePaneIsBusy(text: string): boolean {
114
+ return claudeRegionsAreBusy(claudePaneRegions(text));
115
+ }
116
+
117
+ function claudeRegionsAreBusy({ footer, activity }: ClaudePaneRegions): boolean {
118
+ // Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
119
+ // dropped that hint. Keep both older signals and the 2.1.218 thinking line so
120
+ // the pane veto works across those formats.
121
+ if (footer.includes("esc to interrupt")) return true;
122
+ if (!activity) return CLAUDE_BUSY_SPINNER_RE.test(footer) || CLAUDE_BUSY_THOUGHT_RE.test(footer);
123
+ // Rows rejoin so a counter Claude wrapped at a narrow width still reads (#3026). A thinking
124
+ // row is live only as the block's head: a completed one stays above "⎿ Interrupted" or output.
125
+ return CLAUDE_BUSY_SPINNER_RE.test(activity.join(" ")) || CLAUDE_BUSY_THOUGHT_RE.test(claudeActivityHead(activity));
126
+ }
127
+
128
+ /** The block's last row above any tip Claude prints under its spinner. */
129
+ function claudeActivityHead(activity: string[]): string {
130
+ const tip = activity.findIndex((row) => /^\s*⎿\s+Tip:/.test(row));
131
+ return (tip >= 0 ? activity.slice(0, tip) : activity).at(-1) ?? "";
132
+ }
133
+
134
+ // #1588 — persistent TUI chrome that is present at the prompt regardless of whether a
135
+ // conversation is loaded, so it must NEVER be picked as a pre-clear conversation anchor.
136
+ // (Includes the claudePaneLooksReady footer markers plus the box/hint chrome around them.)
137
+ const CLAUDE_CHROME_MARKERS = [
138
+ "bypass permissions",
139
+ "shift+tab to cycle",
140
+ "? for shortcuts",
141
+ "/effort",
142
+ "Welcome back",
143
+ "Claude Code",
144
+ "esc to interrupt",
145
+ "ctrl+o to expand",
146
+ "/btw",
147
+ "Tip:",
148
+ "Tips for getting started",
149
+ ];
150
+
151
+ // #1588 — pick a distinctive line from the PRE-clear pane that belongs to the loaded
152
+ // conversation body (not the persistent chrome/footer), so its DISAPPEARANCE after `/clear`
153
+ // is positive evidence the conversation was actually wiped. claudePaneLooksReady alone can't
154
+ // tell "before clear" from "after clear" — its footer markers are present in both — so the
155
+ // self-resume barrier (waitForClaudeClearCommitted) keys off this anchor instead. Returns the
156
+ // longest content-rich, chrome-free line, or undefined when the pane has no such line (e.g. an
157
+ // already-empty conversation) — in which case the clear can't be positively confirmed and the
158
+ // caller falls to continuationDelivered:false so the #1023 backstop covers it.
159
+ export function claudeConversationAnchor(pane: string): string | undefined {
160
+ let best: string | undefined;
161
+ for (const raw of claudeConversationRows(pane)) {
162
+ const line = raw.trim();
163
+ if (line.length < 20) continue;
164
+ if (CLAUDE_CHROME_MARKERS.some((marker) => line.includes(marker))) continue;
165
+ // Require substantial alphanumeric content so a box-border / ASCII-art / punctuation
166
+ // line can never serve as the anchor (those can reappear on the fresh cleared screen).
167
+ const alnum = (line.match(/[A-Za-z0-9]/g) ?? []).length;
168
+ if (alnum < 12) continue;
169
+ if (!best || line.length > best.length) best = line;
170
+ }
171
+ return best;
172
+ }
173
+
174
+ const CLAUDE_BANNER_RE = /Claude Code v\d/;
175
+ // A turn opens with the user's prompt or an assistant, tool or event bullet.
176
+ const CLAUDE_TURN_ROW_RE = /^\s*[❯>⏺●]/;
177
+
178
+ // #3151 — the rows `/clear` wipes: none of the startup header (banner, model, cwd, tip), which it
179
+ // repaints, and nothing from the composer down, whose status rows change on their own.
180
+ function claudeConversationRows(pane: string): string[] {
181
+ const lines = pane.trimEnd().split("\n");
182
+ const end = claudeComposerRuleRow(lines) ?? lines.length;
183
+ const banner = lines.findIndex((line) => CLAUDE_BANNER_RE.test(line));
184
+ if (banner < 0 || banner >= end) return lines.slice(0, end);
185
+ const start = lines.findIndex((line, row) => row > banner && row < end && CLAUDE_TURN_ROW_RE.test(line));
186
+ return start < 0 ? [] : lines.slice(start, end);
187
+ }
188
+
189
+ // #1588 — CLEAR-AWARE completion check for the self-resume barrier. The `/clear` keystroke is
190
+ // async in the Claude TUI: submitTextToTmux returns as soon as the keys are typed, and the
191
+ // persistent footer (claudePaneLooksReady) matches the STILL-UNCLEARED pane, so accepting the
192
+ // first ready match let the continuation race the async clear and get wiped (the #1588 window).
193
+ // A clear is treated as COMMITTED only with positive evidence: the pane is (a) not busy (the
194
+ // clear/redraw finished processing), (b) back at the input-ready footer, and (c) no longer shows
195
+ // the pre-clear conversation anchor (the loaded conversation is gone). Any of these missing →
196
+ // NOT committed → keep waiting (and, on timeout, the caller reports continuationDelivered:false
197
+ // so the backstop re-injects — uncertainty resolves toward the backstop, never toward a wipe).
198
+ //
199
+ // #2261 — the anchor test above is the FALLBACK signal, not the primary one, and `currentPane`
200
+ // MUST be a VISIBLE-SCREEN capture (captureTmuxVisiblePaneAsync). Claude's `/clear` repaints only
201
+ // the visible screen, so an anchor that had already scrolled into tmux history stays in a
202
+ // scrollback capture forever and the barrier can never confirm a clear that plainly committed.
203
+ export interface ClaudeClearEvidence {
204
+ /**
205
+ * #2261 — the intrinsic probe's verdict on Claude's session id (./claude-session-probe), ALREADY
206
+ * SETTLED by the caller across a poll interval. Default `unknown` (no probe / pre-2.1.119 /
207
+ * unreadable / liveness-failed / pid changed) keeps the pure anchor behavior.
208
+ */
209
+ session?: ClaudeClearSessionVerdict;
210
+ /** #2849 — pre-clear pane captured AND anchorless: vacuous clear, so idle+ready
211
+ * suffices. Never set from a failed capture — that stays fail-closed. */
212
+ preClearEmpty?: boolean;
213
+ /** #2849r1/#2850 — the poller saw a CLAUDE_VACUOUS_CLEAR_READY_POLLS idle+ready streak.
214
+ * A ready pane can predate the /clear commit (#1588), and with no probe verdict this
215
+ * streak is the only commit evidence. Set by the poller. */
216
+ settledReady?: boolean;
217
+ }
218
+
219
+ export function claudeClearCommitted(
220
+ preClearAnchor: string | undefined,
221
+ currentPane: string,
222
+ evidence: ClaudeClearEvidence = {},
223
+ ): boolean {
224
+ if (claudePaneIsBusy(currentPane)) return false;
225
+ if (!claudePaneLooksReady(currentPane)) return false;
226
+ // #2261 PRIMARY signal — machine-readable and version-robust: committing `/clear` mints a NEW
227
+ // Claude session (fresh sessionId + fresh transcript file), so a same-pid session rotation is
228
+ // direct proof the loaded conversation is gone, independent of any TUI chrome.
229
+ //
230
+ // It is authoritative in BOTH directions, and that is what keeps it a STRENGTHENING rather than
231
+ // a relaxation. `unchanged` means the probe positively says the clear has not committed, so it
232
+ // must VETO — never let the (visible-screen-only) anchor heuristic confirm over it, e.g. when
233
+ // unrelated output has merely scrolled the anchor off the screen. The busy/ready gates above
234
+ // still gate both verdicts, so the #1588 ordering property (never inject while the clear is
235
+ // still processing) is untouched. `unknown` is no evidence: it falls through to the anchor test,
236
+ // whose failure still resolves to false → the #1023 backstop re-injects.
237
+ if (evidence.session === "rotated") return true;
238
+ if (evidence.session === "unchanged") return false;
239
+ // #2849r1 — vacuous clear, BELOW the session veto: an unchanged session still
240
+ // refuses, and a lone ready poll may be the pre-clear footer (#1588), so both
241
+ // settledReady (a long consecutive-ready streak, #2850) and preClearEmpty are required.
242
+ if (evidence.preClearEmpty === true && evidence.settledReady === true) return true;
243
+ if (!preClearAnchor) return false;
244
+ return !currentPane.includes(preClearAnchor);
245
+ }
246
+
247
+ export interface ClaudeActivityReadOptions {
248
+ /** #1621: the probe's busy assertion predates an interrupt relay sent (claudeProbeVerdict),
249
+ * so it may describe a turn that was cancelled. */
250
+ probeBusyStale?: boolean;
251
+ /**
252
+ * #1980 U2 — when the intrinsic session probe is UNAVAILABLE (pre-2.1.119 / unresolvable
253
+ * config-home), may a bare input-ready-looking pane be read as `idle`?
254
+ * true (default) — legacy pane-scrape turn-boundary detection (the monitorless turn-watch):
255
+ * an input-ready footer ends the turn.
256
+ * false — the busy BACKSTOP path: an input-ready-looking footer is NOT positive evidence a
257
+ * turn ended — a long foreground tool renders the exact same prompt box while fully
258
+ * alive (#766/#1980) — so with no probe to corroborate it is SILENCE, reported as
259
+ * `unknown` (the backstop holds the turn) rather than `idle` (which force-cancels it).
260
+ * This is what stops the `no-busy-observed` false-cancel from ever being reached.
261
+ */
262
+ paneFallbackIsIdle?: boolean;
263
+ }
264
+
265
+ export function claudeActivityFromProbeAndPane(
266
+ probe: ClaudeProbeActivity | undefined,
267
+ pane: string,
268
+ opts: ClaudeActivityReadOptions = {},
269
+ ): ClaudeProbeActivity {
270
+ const regions = claudePaneRegions(pane);
271
+ // The pane's live activity is the strongest busy evidence there is and always wins.
272
+ if (claudeRegionsAreBusy(regions)) return "busy";
273
+ const ready = claudePaneLooksReady(regions.footer);
274
+ if (probe === "busy") {
275
+ // Probe-busy is authoritative (#766, #3026). Only after relay's own interrupt (#1621) may a
276
+ // positively input-ready footer overrule it; a shelled-out or unreadable pane keeps it busy.
277
+ return opts.probeBusyStale && ready ? "idle" : "busy";
278
+ }
279
+ // A probe-`idle` is the provider's OWN end-of-turn assertion (positive evidence); a positively
280
+ // input-ready pane corroborates it, an ambiguous pane holds as unknown.
281
+ if (probe === "idle") return ready ? "idle" : "unknown";
282
+ // probe is undefined/unknown here (unavailable, or Claude `waiting`): the pane-scrape fallback.
283
+ // #1980 U2 — an input-ready footer is only trustworthy as turn-end when the caller opts in
284
+ // (paneFallbackIsIdle, the turn-boundary path). For the busy backstop (opt-out) it is silence,
285
+ // held as `unknown`, so a live foreground tool the probe cannot see is never force-cancelled.
286
+ if (ready && (opts.paneFallbackIsIdle ?? true)) return "idle";
287
+ return "unknown";
288
+ }
289
+
290
+ // #286: detect the subscription usage-limit modal in the pane and turn it into the
291
+ // provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
292
+ // at reset. Unlike the pane model-rejection observation below, it remains a legacy status path.
293
+ export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
294
+ const parsed = parseClaudeRateLimitPane(text);
295
+ if (!parsed) return null;
296
+ return {
297
+ status: "idle",
298
+ clear: ["subagent"],
299
+ providerState: buildRateLimitProviderState({
300
+ errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
301
+ ...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
302
+ message: parsed.message,
303
+ source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
304
+ }),
305
+ };
306
+ }
307
+
308
+ // #769 — detect the API connection-retry banner in the live terminal footer. Three stable
309
+ // anchors from the banner text: "Unable to connect to API", "Retrying in <N>s",
310
+ // "attempt <N>/<M>". Gated on the LAST N lines so agent output that merely QUOTES these
311
+ // strings (e.g. quoting this comment, or a discussion of retry logic) doesn't false-positive —
312
+ // the live banner always lives in the footer; quoted strings appear in the scrollback body.
313
+ // Break condition: if CC renames/removes any anchor substring the detector stops firing — a
314
+ // fast fix once the new footer wording is known, and one with no correctness consequence now
315
+ // that this is advisory.
316
+ //
317
+ // #1651 — this returns a DIAGNOSTIC, not a status. It used to return
318
+ // `status: "idle"` + `clear: ["subagent"]` + a blocked `conn_retry` providerState. The runner
319
+ // treats an idle provider-turn as the turn ENDING (runner-core setProviderStatus), which
320
+ // clears the work record and calls completeObservedTaskClaims(): retry chrome rendered in a
321
+ // pane could therefore COMPLETE THE TASK CLAIMS OF A TURN THAT WAS STILL RUNNING. The footer
322
+ // gate did not prevent this — the banner is genuinely in the footer during a live turn, which
323
+ // is exactly when the detector fired.
324
+ //
325
+ // The turn stays live/unknown throughout the retries. Claude's own retry ladder is long
326
+ // (measured at 179s for a refused connection), but lateness is not permission to infer failure
327
+ // from pane text: only a correlated StopFailure may end the turn, after the native ladder
328
+ // exhausts. Disappearance likewise proves nothing — it only clears the diagnostic flag.
329
+ const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
330
+ const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
331
+ const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
332
+ const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
333
+
334
+ export function claudeConnectionRetryObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
335
+ const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
336
+ if (
337
+ !CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
338
+ !CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
339
+ !CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
340
+ ) return null;
341
+ return {
342
+ eventType: "claude.pane_connection_retry_candidate",
343
+ source: "pane",
344
+ detectorId: "connection-retry",
345
+ observedAt: nowMs ?? Date.now(),
346
+ ...(sessionName ? { sessionName } : {}),
347
+ message: "Pane shows Claude's API connection-retry banner. Advisory only: the turn is unchanged.",
348
+ };
349
+ }
350
+
351
+ /** The falling edge. It used to publish a bare `idle` — an unconditional turn-end with no
352
+ * evidence behind it at all. A banner leaving the footer proves nothing about the turn, so
353
+ * this is a diagnostic too: it clears the caller's flag and says so. */
354
+ export function claudeConnectionRetryClearedObservation(sessionName?: string, nowMs?: number): PaneObservation {
355
+ return {
356
+ eventType: "claude.pane_connection_retry_cleared",
357
+ source: "pane",
358
+ detectorId: "connection-retry",
359
+ observedAt: nowMs ?? Date.now(),
360
+ ...(sessionName ? { sessionName } : {}),
361
+ message: "Pane no longer shows the connection-retry banner. Advisory only: proves nothing about the turn.",
362
+ };
363
+ }
364
+
365
+ // #1651 — this returns a DIAGNOSTIC, not a status. It used to return `status: "error"` +
366
+ // `clear: ["provider-turn", "subagent"]` + a TERMINAL failed provider state + suppressed
367
+ // restart from a single pane match — reproduced live: the harmless sentence "The test fixture
368
+ // says: not an available model" triggered every one of those, from a pane, where the matched
369
+ // text may simply be an agent quoting a fixture, a code comment, or this very file.
370
+ //
371
+ // A pane match is never terminal. Only a correlated StopFailure whose native error type
372
+ // explicitly denotes model-not-found/unavailable (not yet wired — see #1651's Direction
373
+ // section) may end a turn this way. This detector only notices.
374
+ //
375
+ // Scoped to the trailing modal/composer structural block: the same positional technique
376
+ // rate-limit.ts's livePaneBlock uses (Claude Code pins its composer to the bottom of the
377
+ // screen, separated from the conversation by a blank row, and draws blocking dialogs in that
378
+ // same spot), reimplemented locally rather than importing that module's private helper. An
379
+ // agent's own scrollback quoting/discussing the rejection text sits above the blank row and
380
+ // so can never reach this scan.
381
+ //
382
+ // Repetition is the caller's job, not this function's: claude.ts requires the SAME message on
383
+ // two consecutive polls before it will even publish the diagnostic — this stays pure so it can
384
+ // be unit tested without threading adapter state through it.
385
+ export function claudeModelUnavailableObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
386
+ const block = trailingStructuralBlock(text);
387
+ const message = extractClaudeModelUnavailableMessage(block);
388
+ if (!message) return null;
389
+ return {
390
+ eventType: "claude.pane_model_unavailable_candidate",
391
+ source: "pane",
392
+ detectorId: "model-unavailable",
393
+ observedAt: nowMs ?? Date.now(),
394
+ ...(sessionName ? { sessionName } : {}),
395
+ message,
396
+ };
397
+ }
398
+
399
+ export interface ClaudeModelUnavailableObservationState {
400
+ message?: string;
401
+ reported: boolean;
402
+ }
403
+
404
+ export function initialClaudeModelUnavailableObservationState(): ClaudeModelUnavailableObservationState {
405
+ return { reported: false };
406
+ }
407
+
408
+ /** Require two identical reads before a pane candidate reaches diagnostic telemetry. */
409
+ export function debounceClaudeModelUnavailableObservation(
410
+ observation: PaneObservation | null,
411
+ state: ClaudeModelUnavailableObservationState,
412
+ ): { state: ClaudeModelUnavailableObservationState; observation?: PaneObservation } {
413
+ if (!observation?.message) return { state: initialClaudeModelUnavailableObservationState() };
414
+ if (state.message !== observation.message) return { state: { message: observation.message, reported: false } };
415
+ if (state.reported) return { state };
416
+ return { state: { ...state, reported: true }, observation };
417
+ }
418
+
419
+ function trailingStructuralBlock(text: string): string {
420
+ const lines = text.split(/\r?\n/);
421
+ let end = lines.length;
422
+ while (end > 0 && lines[end - 1]!.trim() === "") end--;
423
+ let start = end;
424
+ while (start > 0 && lines[start - 1]!.trim() !== "") start--;
425
+ return lines.slice(start, end).join("\n");
426
+ }
@@ -0,0 +1,93 @@
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
+ import { harnessSubagentsEnabled } from "@humwick/sdk";
3
+ import { claudeModelFamily, extractCallerSettings } from "../claude-settings";
4
+ import { shellQuote } from "@humwick/sdk/shell-utils";
5
+ import { resolve } from "node:path";
6
+ import type { ProvisioningHookSet } from "@humwick/sdk";
7
+ import type { RunnerSpawnConfig, SpawnArgs } from "../adapter";
8
+ import { claudeVersionAtLeast } from "./claude-session-probe";
9
+
10
+ export function claudeSubagentPolicy(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">) {
11
+ const options = config.agentProfile?.providerOptions?.claude;
12
+ if (!config.agentProfile || options?.subagentModel === null) return undefined;
13
+ return { model: options?.subagentModel ?? "sonnet", effort: options?.subagentMaxEffort ?? "high", contextTokens: options?.subagentContextTokens ?? 200_000 };
14
+ }
15
+
16
+ export function claudeSubagentEnv(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): Record<string, string> {
17
+ const policy = claudeSubagentPolicy(config);
18
+ const enabled = writeChildEnv({}, { AGENT_RELAY_HARNESS_SUBAGENTS: harnessSubagentsEnabled(config) ? "1" : "0" });
19
+ return policy ? writeChildEnv({}, { ...enabled,
20
+ CLAUDE_CODE_SUBAGENT_MODEL: policy.model,
21
+ CLAUDE_CODE_SUBAGENT_MODEL_FORCE: "1",
22
+ CLAUDE_CODE_FORK_SUBAGENT: "0",
23
+ CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "0",
24
+ CLAUDE_CODE_DISABLE_WORKFLOWS: "1",
25
+ AGENT_RELAY_CLAUDE_SUBAGENT_CAP: "1",
26
+ }) : writeChildEnv({}, { ...enabled, AGENT_RELAY_CLAUDE_SUBAGENT_CAP: "0" });
27
+ }
28
+
29
+ export function claudeSubagentSettings(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile" | "model">): Record<string, unknown> {
30
+ const policy = claudeSubagentPolicy(config);
31
+ if (!policy) return harnessSubagentsEnabled(config) ? {} : { disableAllHooks: false };
32
+ const mainFamily = claudeModelFamily(config.model);
33
+ // Model settings apply to both parent and children, including canonicalized aliases (#3424).
34
+ const modelSettings = mainFamily && mainFamily !== policy.model
35
+ ? { [policy.model]: { maxEffortLevel: policy.effort, autoCompactWindow: policy.contextTokens } } : undefined;
36
+ return { disableAllHooks: false, ...(modelSettings ? { modelSettings } : {}), autoCompactEnabled: true };
37
+ }
38
+
39
+ // Always supplied through settings, including profiles with the Relay plugin switched off.
40
+ export function claudeSubagentHooks(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">): ProvisioningHookSet {
41
+ const stop = { SubagentStop: [{ hooks: [{ type: "command" as const, command: `bash "${resolve(import.meta.dir, "../../plugins/claude/hooks/subagent-stop.sh")}"`, timeout: 5 }] }] };
42
+ if (harnessSubagentsEnabled(config) && !claudeSubagentPolicy(config)) return stop;
43
+ const guard = { type: "command" as const, command: `bun ${shellQuote(resolve(import.meta.dir, "../../plugins/claude/hooks/subagent-guard.cjs"))}`, timeout: 5, onFailure: "block" as const };
44
+ return { ...stop,
45
+ PreToolUse: [{ matcher: "Agent|Task|Skill|TeamCreate|TeamDelete|SendMessage|Workflow", hooks: [guard] }],
46
+ UserPromptExpansion: [{ hooks: [guard] }],
47
+ UserPromptSubmit: [{ hooks: [guard] }],
48
+ };
49
+ }
50
+
51
+ export function assertClaudeSubagentVersion(version: string): void {
52
+ if (!claudeVersionAtLeast(version, "2.1.296")) throw new Error(`Claude sub-agent caps require Claude Code >=2.1.296; installed: ${version || "unknown"}`);
53
+ }
54
+
55
+ export async function verifyClaudeSubagentVersion(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile" | "env" | "cwd">): Promise<void> {
56
+ if (!claudeSubagentPolicy(config)) return;
57
+ const child = Bun.spawn(["claude", "--version"], { cwd: config.cwd, env: config.env, stdout: "pipe", stderr: "pipe", timeout: 10_000 });
58
+ const [stdout, stderr, exit] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited]);
59
+ if (exit !== 0) throw new Error(`Cannot verify Claude sub-agent cap support: ${stderr.trim() || exit}`);
60
+ assertClaudeSubagentVersion(stdout.trim());
61
+ }
62
+
63
+ // Pass source locations to the runtime resolver; never inspect skill definitions at launch.
64
+ export function configureClaudeSubagentLaunch(config: Pick<RunnerSpawnConfig, "harnessSubagents" | "agentProfile">, launch: Pick<SpawnArgs, "args" | "env" | "cwd">): void {
65
+ if (harnessSubagentsEnabled(config) && !claudeSubagentPolicy(config)) return;
66
+ const values = (flag: string, variadic = false) => {
67
+ const out: string[] = [];
68
+ for (let i = 0; i < launch.args.length; i++) {
69
+ const arg = launch.args[i]!;
70
+ if (arg.startsWith(`${flag}=`)) out.push(arg.slice(flag.length + 1));
71
+ else if (arg === flag) do {
72
+ if (!launch.args[i + 1] || launch.args[i + 1]!.startsWith("-")) break;
73
+ out.push(launch.args[++i]!);
74
+ } while (variadic);
75
+ }
76
+ return out;
77
+ };
78
+ if (launch.args.some((arg) => /^--(?:bare|safe-mode|autocompact)(?:=|$)/.test(arg))) {
79
+ throw new Error("Capped Claude profiles cannot use --bare, --safe-mode or --autocompact; they suppress mandatory hooks or override compaction policy");
80
+ }
81
+ if (["CLAUDE_CODE_SIMPLE", "CLAUDE_CODE_SAFE_MODE"].some((key) => ["1", "true"].includes(relayEnv(launch.env, key) ?? ""))) throw new Error("Capped Claude profiles cannot suppress mandatory hooks through ambient bare or safe mode");
82
+ const sources = (values("--setting-sources").at(-1) ?? "user,project,local").split(",");
83
+ if (!sources.includes("user")) throw new Error("Capped Claude profiles require user settings to load mandatory hooks");
84
+ const settings = extractCallerSettings([launch.args], launch.cwd)?.settings ?? {};
85
+ const callerEnv = settings.env as Record<string, unknown> | undefined;
86
+ const protectedEnv = [...Object.keys(claudeSubagentEnv(config)), "CLAUDE_CODE_SIMPLE", "CLAUDE_CODE_SAFE_MODE", "CLAUDE_CODE_AUTO_COMPACT_WINDOW", "DISABLE_AUTO_COMPACT", "AGENT_RELAY_CLAUDE_SKILL_SOURCES"];
87
+ if (callerEnv && protectedEnv.some((key) => key in callerEnv)) throw new Error("Capped Claude profiles cannot override child policy through --settings env");
88
+ writeChildEnv(launch.env, { AGENT_RELAY_CLAUDE_SKILL_SOURCES: JSON.stringify({ cwd: launch.cwd,
89
+ sources,
90
+ directories: values("--add-dir", true), plugins: values("--plugin-dir", true),
91
+ settings: { enabledPlugins: settings.enabledPlugins, skillOverrides: settings.skillOverrides },
92
+ }) });
93
+ }