@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,80 @@
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
+ // #2947 — the orchestrator hands the runner the project's toolchain PATH plus shell hooks that re-lead
3
+ // it after the user's shell init. The provider process keeps the host PATH; only its shell tool re-leads.
4
+ // The same hooks keep the agent's private TMPDIR when a login profile resets it (#3084).
5
+ // A hook the user already configured is chained behind ours, never dropped; an inherited Relay hook never is (#3084).
6
+ import { resolve } from "node:path";
7
+ import { browserToolsBinDir, isRecord, PROJECT_ENV_FILE_ENV, PROJECT_PATH_ENV, PROJECT_ZDOTDIR_ENV, RELAY_BIN_PATH_ENV } from "@humwick/sdk";
8
+ import { AGENT_TMPDIR_ENV } from "@humwick/sdk/host-tmpdir";
9
+ import { providerScratchEnv } from "@humwick/sdk/provider-scratch";
10
+ import { composeShellHooks, PROJECT_ENV_SCRIPT, PROJECT_ZDOTDIR } from "@humwick/sdk/shell-hooks";
11
+ import { tomlString } from "../relay-mcp";
12
+ import { humwickHome } from "../config";
13
+ import { providerSourceHome } from "../profile-home";
14
+ import { readCodexConfig } from "./codex-effective-config";
15
+ import { serviceAccessEnvKeys } from "./service-access";
16
+
17
+ /** #3084 — every launch, orchestrator-spawned or direct: the agent's private TMPDIR plus the hooks that keep
18
+ * it (and #2947's toolchain PATH) after shell init. */
19
+ export async function agentShellLaunchEnv(agentId: string, operatorEnv?: Record<string, string>): Promise<Record<string, string>> {
20
+ return writeChildEnv({}, { ...await providerScratchEnv(agentId, undefined, operatorEnv), [PROJECT_ENV_FILE_ENV]: PROJECT_ENV_SCRIPT, [PROJECT_ZDOTDIR_ENV]: PROJECT_ZDOTDIR, [RELAY_BIN_PATH_ENV]: relayBinPath() });
21
+ }
22
+
23
+ /** #3489 — this runner's own `agent-relay-browser` and the host's provisioned `playwright-cli`: a login shell's
24
+ * PATH (an older global install) can never pick which Relay release or browser tools an agent runs. */
25
+ export function relayBinPath(): string {
26
+ return [resolve(import.meta.dir, "../../agent-bin"), browserToolsBinDir(humwickHome())].join(":");
27
+ }
28
+
29
+ /** Claude Code sources CLAUDE_ENV_FILE before every Bash tool command, after the user's shell init. */
30
+ export function claudeProjectShellEnv(env: Record<string, string>): Record<string, string> {
31
+ const file = relayEnv(env, PROJECT_ENV_FILE_ENV);
32
+ if (!shellHookNeeded(env) || !file) return {};
33
+ return composeShellHooks((key) => relayEnv(env, key), { CLAUDE_ENV_FILE: file });
34
+ }
35
+
36
+ /** Codex runs tool commands as `<login shell> -lc`: bash reads BASH_ENV, zsh reads its startup files from
37
+ * ZDOTDIR. `shell_environment_policy.set` reaches those commands only, never the app-server. The user's
38
+ * own values come from that same policy (config.toml, then `configArgs`), else the inherited env. */
39
+ export function codexProjectShellConfigArgs(env: Record<string, string>, configArgs: string[] = [], codexHome?: string): string[] {
40
+ const path = relayEnv(env, PROJECT_PATH_ENV);
41
+ if (!shellHookNeeded(env)) return [];
42
+ const configured = codexShellPolicySet(configArgs, codexHome || providerSourceHome("codex"));
43
+ if (relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE")) {
44
+ for (const key of serviceAccessEnvKeys(env)) if (relayEnv(configured, key) !== undefined && relayEnv(configured, key) !== relayEnv(env, key)) throw new Error("vault_env_conflict:shell:" + key);
45
+ const policy = codexShellPolicy(configArgs, codexHome || providerSourceHome("codex"));
46
+ if ((Array.isArray(policy.exclude) && policy.exclude.length) || (Array.isArray(policy.include_only) && policy.include_only.length) || (policy.inherit && policy.inherit !== "all")) throw new Error("vault_env_conflict:shell_environment_policy");
47
+ }
48
+ const user = (key: string) => typeof relayEnv(configured, key) === "string" ? relayEnv(configured, key) as string : relayEnv(env, key);
49
+ const set: Record<string, string | undefined> = writeChildEnv({}, {
50
+ [PROJECT_PATH_ENV]: path,
51
+ [RELAY_BIN_PATH_ENV]: relayEnv(env, RELAY_BIN_PATH_ENV),
52
+ ...composeShellHooks(user, { BASH_ENV: relayEnv(env, PROJECT_ENV_FILE_ENV), ZDOTDIR: relayEnv(env, PROJECT_ZDOTDIR_ENV) }),
53
+ });
54
+ // An empty chain variable is set too: it clears a stale one the app-server env would hand down.
55
+ return [...(relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE") ? ["-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true"] : []),
56
+ ...Object.entries(set).flatMap(([key, value]) => value === undefined ? [] : ["-c", `shell_environment_policy.set.${key}=${tomlString(value)}`])];
57
+ }
58
+
59
+ /** OpenCode has no tool-only env: its server env reaches every shell it runs. */
60
+ export function openCodeProjectShellEnv(env: Record<string, string>): Record<string, string> {
61
+ const file = relayEnv(env, PROJECT_ENV_FILE_ENV), zdotdir = relayEnv(env, PROJECT_ZDOTDIR_ENV);
62
+ if (!shellHookNeeded(env) || !file) return {};
63
+ return composeShellHooks((key) => relayEnv(env, key), { BASH_ENV: file, ...(zdotdir ? { ZDOTDIR: zdotdir } : {}) });
64
+ }
65
+
66
+ function shellHookNeeded(env: Record<string, string>): boolean {
67
+ return Boolean(relayEnv(env, PROJECT_PATH_ENV) || relayEnv(env, AGENT_TMPDIR_ENV) || relayEnv(env, "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE"));
68
+ }
69
+
70
+ /** Codex's own `shell_environment_policy.set` without us: CODEX_HOME/config.toml, then `-c` overrides in order. */
71
+ function codexShellPolicySet(configArgs: string[], codexHome: string): Record<string, unknown> {
72
+ const policy = codexShellPolicy(configArgs, codexHome);
73
+ return isRecord(policy.set) ? policy.set : {};
74
+ }
75
+
76
+ function codexShellPolicy(configArgs: string[], codexHome: string): Record<string, unknown> {
77
+ const config = readCodexConfig(configArgs, codexHome);
78
+ const policy = config.shell_environment_policy;
79
+ return isRecord(policy) ? policy : {};
80
+ }
@@ -0,0 +1,21 @@
1
+ import type { SpawnProvider } from "@humwick/sdk";
2
+ import type { ProviderConfig, RunnerSpawnConfig, SpawnArgs } from "../adapter";
3
+ import { primaryProviderInvocation, type ProviderInvocation } from "../provider-double";
4
+ import { buildCodexTuiSpawnArgs } from "./codex-launch-config";
5
+
6
+ /** Provider-owned supplemental process roles derived from the production launch configuration. */
7
+ export function buildSupplementalProviderInvocations(
8
+ provider: SpawnProvider,
9
+ config: RunnerSpawnConfig,
10
+ providerConfig: ProviderConfig,
11
+ primary: SpawnArgs,
12
+ ): ProviderInvocation[] {
13
+ if (provider !== "codex") return [];
14
+ const tui = buildCodexTuiSpawnArgs(
15
+ config,
16
+ providerConfig,
17
+ primary.env.CODEX_APP_SERVER_URL ?? "",
18
+ primary.env,
19
+ );
20
+ return tui ? [{ ...primaryProviderInvocation(provider, tui), role: "tui" }] : [];
21
+ }
@@ -0,0 +1,127 @@
1
+ import { writeChildEnv } from "@humwick/sdk/env";
2
+ import type { QuotaState, HumwickHttpClient } from "@humwick/sdk";
3
+ import { getManifest } from "@humwick/providers";
4
+ import { quotaStateFromProbeMetrics } from "@humwick/sdk/context-probe";
5
+ import { claudeQuotaGeneration, claudeQuotaUnavailableWindows } from "@humwick/sdk/provider-quota-order";
6
+ import { readRunnerContextProbeState } from "../context-probe-state";
7
+ import { providerHostConfigHome } from "../profile-home";
8
+ import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveClaudeStatuslineQuotaIdentity, type ProviderQuotaIdentity } from "../quota";
9
+
10
+ const PASSIVE_QUOTA_CONFIG_REFRESH_MS = 60_000;
11
+ const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
12
+ const PASSIVE_QUOTA_LEASE_MAX_TTL_MS = 10 * 60_000;
13
+ const PASSIVE_QUOTA_LEASE_PADDING_MS = 60_000;
14
+
15
+ interface ProviderQuotaHarvestOptions {
16
+ agentId: string;
17
+ provider: string;
18
+ /** The runner's per-provider config env (central provider-config values), read lazily each publish. */
19
+ providerConfigEnv: () => Record<string, string>;
20
+ http: () => Pick<HumwickHttpClient, "acquireProviderQuotaPublisherLease" | "getProviderQuotaConfig" | "upsertProviderQuota">;
21
+ }
22
+
23
+ export class ProviderQuotaHarvest {
24
+ private config = { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
25
+ private configRefreshAt = 0;
26
+ private lastReportAt?: number;
27
+ private lastReportedObservedAt?: number;
28
+ private lease?: { accountKey: string; leaseToken: string; expiresAt: number };
29
+ private nextLeaseAttemptAt?: number;
30
+
31
+ constructor(private readonly options: ProviderQuotaHarvestOptions) {}
32
+
33
+ async publish(): Promise<void> {
34
+ // #2514 — the statusline harvest serves the whole claude FAMILY (claude + claude-b): the
35
+ // manifest's shim kind is the family declaration, so a second-subscription provider reuses
36
+ // this path under its own id without a provider list here.
37
+ if (getManifest(this.options.provider)?.shim?.kind !== "claude-relay") return;
38
+ const probeMetrics = readRunnerContextProbeState(this.options.agentId);
39
+ const now = Date.now();
40
+ const quota = probeMetrics ? freshQuotaFromProbeMetrics(probeMetrics, now) : undefined;
41
+ // An idle session re-sends nothing new; skipping lets its lease lapse to an active session (#3082).
42
+ if (!quota || (this.lastReportedObservedAt !== undefined && quota.updatedAt <= this.lastReportedObservedAt)) return;
43
+ const config = await this.refreshConfig();
44
+ if (!config.enabled) return;
45
+ if (this.lastReportAt !== undefined && now - this.lastReportAt < config.pollIntervalMs) return;
46
+ const identity = await this.resolveIdentity();
47
+ if (!identity) return;
48
+ if (!await this.ensurePublisherLease(identity.accountKey, config.pollIntervalMs, now)) return;
49
+ await this.options.http().upsertProviderQuota({
50
+ provider: this.options.provider,
51
+ accountKey: identity.accountKey,
52
+ quota,
53
+ unavailableWindows: claudeQuotaUnavailableWindows(quota),
54
+ lastAttemptAt: now,
55
+ sourceAgentId: this.options.agentId,
56
+ });
57
+ this.lastReportAt = now;
58
+ this.lastReportedObservedAt = quota.updatedAt;
59
+ }
60
+
61
+ // #2514 — quota identity resolves exactly as the LAUNCH authenticates. The primary claude
62
+ // runner keeps the historical ambient-env read byte-for-byte. A claude-family provider with
63
+ // its own subscription (claude-b) must never inherit the ambient primary credential env
64
+ // (CLAUDE_CODE_OAUTH_TOKEN outranks the file credential, so the B quota would be keyed — and
65
+ // folded — under the A account): its identity comes from its OWN dedicated home plus its
66
+ // provider-scoped config env, mirroring the adapter launch scrub and the orchestrator's
67
+ // providerCredentialProbeEnv precedence.
68
+ private async resolveIdentity(): Promise<ProviderQuotaIdentity | undefined> {
69
+ if (this.options.provider === "claude") {
70
+ return resolveClaudeStatuslineQuotaIdentity({ env: process.env });
71
+ }
72
+ const providerEnv = this.options.providerConfigEnv();
73
+ return resolveClaudeStatuslineQuotaIdentity({
74
+ provider: this.options.provider,
75
+ env: writeChildEnv({}, { CLAUDE_CODE_OAUTH_TOKEN: providerEnv.CLAUDE_CODE_OAUTH_TOKEN }),
76
+ configDir: providerHostConfigHome(this.options.provider),
77
+ });
78
+ }
79
+
80
+ private async refreshConfig() {
81
+ const now = Date.now();
82
+ if (now < this.configRefreshAt) return this.config;
83
+ this.configRefreshAt = now + PASSIVE_QUOTA_CONFIG_REFRESH_MS;
84
+ try {
85
+ const map = await this.options.http().getProviderQuotaConfig();
86
+ const providerCfg = map[this.options.provider];
87
+ this.config = providerCfg
88
+ ? normalizeProviderQuotaConfig(providerCfg)
89
+ : { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
90
+ } catch {
91
+ // Keep the previous config; defaults preserve collection if the relay is older
92
+ // or temporarily unreachable during a liveness tick.
93
+ }
94
+ return this.config;
95
+ }
96
+
97
+ private async ensurePublisherLease(accountKey: string, pollIntervalMs: number, now: number): Promise<boolean> {
98
+ if (this.lease?.accountKey === accountKey && this.lease.expiresAt > now) return true;
99
+ if (this.nextLeaseAttemptAt !== undefined && now < this.nextLeaseAttemptAt) return false;
100
+ const leaseToken = this.lease?.accountKey === accountKey ? this.lease.leaseToken : undefined;
101
+ const result = await this.options.http().acquireProviderQuotaPublisherLease({
102
+ provider: this.options.provider,
103
+ accountKey,
104
+ sourceAgentId: this.options.agentId,
105
+ ...(leaseToken ? { leaseToken } : {}),
106
+ ttlMs: Math.min(PASSIVE_QUOTA_LEASE_MAX_TTL_MS, pollIntervalMs + PASSIVE_QUOTA_LEASE_PADDING_MS),
107
+ });
108
+ if (!result.acquired || !result.lease) {
109
+ this.lease = undefined;
110
+ this.nextLeaseAttemptAt = now + Math.min(result.retryAfterMs ?? pollIntervalMs, pollIntervalMs);
111
+ return false;
112
+ }
113
+ this.lease = { accountKey, leaseToken: result.lease.leaseToken, expiresAt: result.lease.expiresAt };
114
+ this.nextLeaseAttemptAt = undefined;
115
+ return true;
116
+ }
117
+ }
118
+
119
+ function freshQuotaFromProbeMetrics(metrics: NonNullable<ReturnType<typeof readRunnerContextProbeState>>, now: number): QuotaState | undefined {
120
+ if (now - (metrics.quotaObservedAt ?? metrics.timestamp) > PASSIVE_QUOTA_SAMPLE_STALE_MS) return undefined;
121
+ const quota = quotaStateFromProbeMetrics(metrics);
122
+ if (!quota) return undefined;
123
+ const windows = quota.windows.filter((window) => window.resetsAt === undefined || window.resetsAt > now);
124
+ const live: QuotaState = { ...quota, windows };
125
+ // Do not turn an expired cached 5h reading into a fresh weekly-only observation (#3087).
126
+ return windows.length > 0 && (claudeQuotaGeneration(live) !== undefined || claudeQuotaUnavailableWindows(quota).length > 0) ? live : undefined;
127
+ }
@@ -0,0 +1,153 @@
1
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
2
+ import { X509Certificate } from "node:crypto";
3
+ import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join } from "node:path";
5
+ import { rootCertificates } from "node:tls";
6
+ import type { ProvisioningMcpServer } from "@humwick/sdk";
7
+ import { shellQuote } from "@humwick/sdk/shell-utils";
8
+ import { activeSharedMcpServers, PROJECT_ENV_FILE_ENV, PROJECT_ZDOTDIR_ENV } from "@humwick/sdk";
9
+ import { PROJECT_ENV_SCRIPT, PROJECT_ZDOTDIR } from "@humwick/sdk/shell-hooks";
10
+ import type { RunnerSpawnConfig } from "../adapter";
11
+ import { extractCallerSettings } from "../claude-settings";
12
+ import { codexConfigOverrides, isRecord } from "@humwick/sdk";
13
+ import { projectSettingSources, providerHomePathFor, providerSourceHome } from "../profile-home";
14
+
15
+ export const SERVICE_ACCESS_ENV_FILE = "AGENT_RELAY_SERVICE_ACCESS_ENV_FILE";
16
+ const PROXY_CA_ENV_KEYS = ["SSL_CERT_FILE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE"] as const;
17
+ export const PROXY_TRUST_ENV_KEYS = ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "NO_PROXY", "no_proxy", "NODE_USE_ENV_PROXY", ...PROXY_CA_ENV_KEYS] as const;
18
+
19
+ function fail(code: string): never { throw new Error(code); }
20
+
21
+ function readLaunchFile(file: string): string {
22
+ const stat = statSync(file);
23
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) fail("vault_invalid_launch_file");
24
+ return readFileSync(file, "utf8");
25
+ }
26
+
27
+ export function serviceAccessEnvKeys(env: Record<string, string>): string[] {
28
+ return [...PROXY_TRUST_ENV_KEYS, SERVICE_ACCESS_ENV_FILE, ...Object.keys(env).filter((key) => relayEnv(env, key) === "__relay_dummy__")];
29
+ }
30
+
31
+ /** Runs after profile/provider env merging, before hydration or any child starts. */
32
+ export function prepareServiceAccessConfig(config: RunnerSpawnConfig): RunnerSpawnConfig {
33
+ const access = config.serviceAccess;
34
+ if (!access) {
35
+ const servers = Object.values(config.agentProfile?.provisioning?.mcpServers ?? {});
36
+ if (servers.some((server) => server.serviceAccess?.services.length)) fail("vault_mcp_service_ungranted");
37
+ return config;
38
+ }
39
+ if (!access.caPem || !access.caFingerprint || !access.services?.length) fail("vault_missing_trust");
40
+ try {
41
+ const cert = new X509Certificate(access.caPem);
42
+ if (!cert.ca || cert.fingerprint256.replaceAll(":", "").toLowerCase() !== access.caFingerprint || Date.parse(cert.validTo) <= Date.now()) fail("vault_ca_mismatch");
43
+ } catch { fail("vault_ca_mismatch"); }
44
+ if (access.expiresAt <= Date.now()) fail("vault_token_expired");
45
+ const inherited = writeChildEnv({}, { ...config.env, ...config.providerConfig.env });
46
+ if (inherited.NODE_TLS_REJECT_UNAUTHORIZED === "0" || inherited.GIT_SSL_NO_VERIFY === "true") fail("vault_tls_verification_disabled");
47
+ // Ambient routing cannot silently bypass a brokered host, even through ALL_PROXY.
48
+ for (const key of ["HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY", "all_proxy", "NO_PROXY", "no_proxy"]) {
49
+ if (relayEnv(inherited, key)) fail("vault_env_conflict:" + key);
50
+ }
51
+ const proxy = new URL(access.proxyUrl);
52
+ if (!["http:", "https:"].includes(proxy.protocol) || proxy.username || proxy.password) fail("vault_invalid_proxy");
53
+ proxy.username = access.token; proxy.password = access.vault;
54
+ const bypass = new Set(["localhost", "127.0.0.1", "[::1]"]);
55
+ for (const endpoint of [config.relayUrl, config.relayMcpEndpoint, config.sharedMcpUrl]) {
56
+ if (endpoint) bypass.add(new URL(endpoint).hostname);
57
+ }
58
+ if (access.services.some((service) => bypass.has(service.host))) fail("vault_service_bypass");
59
+ const extraRoots: string[] = [];
60
+ for (const key of PROXY_CA_ENV_KEYS) {
61
+ const value = relayEnv(inherited, key);
62
+ if (!value) continue;
63
+ try { extraRoots.push(readLaunchFile(value)); } catch { fail("vault_missing_trust:" + key); }
64
+ }
65
+ const scratch = inherited.TMPDIR;
66
+ if (!scratch || !isAbsolute(scratch)) fail("vault_missing_private_tmpdir");
67
+ const dummy: Record<string, string> = {};
68
+ for (const service of access.services) for (const [key, value] of Object.entries(service.dummyEnv)) {
69
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || value !== "__relay_dummy__" || (relayEnv(inherited, key) && relayEnv(inherited, key) !== value) || (PROXY_TRUST_ENV_KEYS as readonly string[]).includes(key)) fail("vault_env_conflict:" + key);
70
+ dummy[key] = value;
71
+ }
72
+ mkdirSync(scratch, { recursive: true, mode: 0o700 });
73
+ const dir = mkdtempSync(join(scratch, "vault-launch-"));
74
+ const bundle = join(dir, "roots.pem"), script = join(dir, "env.sh");
75
+ const systemRoots = ["/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt", "/etc/ssl/cert.pem"].flatMap((file) => {
76
+ try { return [readLaunchFile(file)]; } catch { return []; }
77
+ });
78
+ const combined = [...rootCertificates, ...systemRoots, ...extraRoots, access.caPem].join("\n");
79
+ writeFileSync(bundle, combined, { mode: 0o600 });
80
+ accessSync(bundle, constants.R_OK);
81
+ const env: Record<string, string> = writeChildEnv({}, {
82
+ ...dummy, HTTPS_PROXY: proxy.href, HTTP_PROXY: proxy.href, https_proxy: proxy.href, http_proxy: proxy.href,
83
+ NO_PROXY: [...bypass].join(","), no_proxy: [...bypass].join(","),
84
+ ...Object.fromEntries(PROXY_CA_ENV_KEYS.map((key) => [key, bundle])), NODE_USE_ENV_PROXY: "1", [SERVICE_ACCESS_ENV_FILE]: script,
85
+ });
86
+ writeFileSync(script, Object.entries(env).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join("\n") + "\n", { mode: 0o600 });
87
+ return { ...config, providerConfig: { ...config.providerConfig, env: writeChildEnv({}, { ...config.providerConfig.env, ...env }) }, serviceAccessEnv: env, env: writeChildEnv({}, { ...config.env, ...env, [PROJECT_ENV_FILE_ENV]: PROJECT_ENV_SCRIPT, [PROJECT_ZDOTDIR_ENV]: PROJECT_ZDOTDIR }) };
88
+ }
89
+
90
+ /** References carry the child's env into native MCP config without writing the proxy token. */
91
+ export function serviceAccessMcpServers(config: RunnerSpawnConfig, servers: Record<string, ProvisioningMcpServer>): Record<string, ProvisioningMcpServer> {
92
+ servers = activeSharedMcpServers(servers);
93
+ if (!config.serviceAccessEnv) {
94
+ return servers;
95
+ }
96
+ const keys = Object.keys(config.serviceAccessEnv);
97
+ return Object.fromEntries(Object.entries(servers).map(([name, server]) => {
98
+ const required = server.serviceAccess?.services ?? [];
99
+ if (required.some((service) => !config.serviceAccess?.services?.some((entry) => entry.name === service))) fail("vault_mcp_service_ungranted:" + name);
100
+ if (!server.command || server.url) {
101
+ const referencesDummy = Object.keys(config.serviceAccessEnv!).some((key) => key in (server.env ?? {}) || key in (server.configEnv ?? {}));
102
+ if (required.length || referencesDummy) fail("unsupported_execution_boundary:" + name);
103
+ return [name, server];
104
+ }
105
+ for (const key of keys) {
106
+ const value = relayEnv(server.env, key) ?? relayEnv(server.configEnv, key);
107
+ if (value !== undefined && value !== `\${${key}}` && value !== config.serviceAccessEnv![key]) fail("vault_env_conflict:mcp:" + name + ":" + key);
108
+ }
109
+ if (config.provider === "codex") {
110
+ const env = writeChildEnv({}, { ...server.env }), configEnv = writeChildEnv({}, { ...server.configEnv });
111
+ for (const key of keys) { writeChildEnv(env, {}, [key]); writeChildEnv(configEnv, {}, [key]); }
112
+ return [name, { ...server, env, configEnv, env_vars: [...new Set([...(Array.isArray(server.env_vars) ? server.env_vars : Object.keys(server.env_vars ?? {})), ...keys])] }];
113
+ }
114
+ return [name, { ...server, env: writeChildEnv({}, { ...server.env, ...Object.fromEntries(keys.map((key) => [key, `\${${key}}`])) }) }];
115
+ }));
116
+ }
117
+
118
+ export function serviceAccessLaunchEnv(config: RunnerSpawnConfig): Record<string, string> {
119
+ if (config.serviceAccess && !config.serviceAccessEnv) fail("vault_overlay_not_prepared");
120
+ for (const [key, value] of Object.entries(config.serviceAccessEnv ?? {})) {
121
+ if (relayEnv(config.env, key) !== value || (relayEnv(config.providerConfig.env, key) !== undefined && relayEnv(config.providerConfig.env, key) !== value)) fail("vault_env_conflict:" + key);
122
+ }
123
+ if (config.serviceAccessEnv) {
124
+ const caller = extractCallerSettings([config.providerConfig.defaultArgs, config.providerArgs], config.cwd);
125
+ if (caller && !caller.settings) fail("vault_unreadable_settings");
126
+ const settingsEnv = caller?.settings?.env;
127
+ const checkSettings = (settings: unknown) => {
128
+ const env = isRecord(settings) && isRecord(settings.env) ? settings.env : {};
129
+ for (const key of Object.keys(config.serviceAccessEnv!)) if (relayEnv(env, key) !== undefined && relayEnv(env, key) !== config.serviceAccessEnv![key]) fail("vault_env_conflict:settings:" + key);
130
+ if (env.NODE_TLS_REJECT_UNAUTHORIZED === "0") fail("vault_tls_verification_disabled");
131
+ };
132
+ checkSettings({ env: settingsEnv });
133
+ if (config.provider.startsWith("claude")) {
134
+ let sources = projectSettingSources(config) ?? ["user", "project", "local"];
135
+ const args = [...config.providerConfig.defaultArgs, ...config.providerArgs];
136
+ for (let i = 0; i < args.length; i++) {
137
+ if (args[i] === "--setting-sources") sources = (args[++i] ?? "").split(",");
138
+ else if (args[i]!.startsWith("--setting-sources=")) sources = args[i]!.slice("--setting-sources=".length).split(",");
139
+ }
140
+ const files = sources.includes("user") ? [join(providerHomePathFor(config.provider, config) ?? providerSourceHome(config.provider), "settings.json")] : [];
141
+ for (let dir = config.cwd; ; dir = dirname(dir)) {
142
+ if (sources.includes("project")) files.push(join(dir, ".claude/settings.json"));
143
+ if (sources.includes("local")) files.push(join(dir, ".claude/settings.local.json"));
144
+ if (dirname(dir) === dir) break;
145
+ }
146
+ for (const file of files) if (existsSync(file)) checkSettings(JSON.parse(readLaunchFile(file)));
147
+ }
148
+ if (config.provider === "codex") for (const override of codexConfigOverrides([config.providerConfig.defaultArgs, config.providerArgs].flat())) {
149
+ if (/^mcp_servers\./.test(override) && /\.(?:env|env_vars)(?:\.|\s*=)/.test(override)) fail("vault_env_conflict:codex_mcp_override");
150
+ }
151
+ }
152
+ return config.serviceAccessEnv ?? {};
153
+ }
@@ -0,0 +1,207 @@
1
+ // Off-loop tmux subprocess transport (#1838 Wave 2, rows 12/14/15/16 — first real
2
+ // adoption of the #1809/#1822 offload primitive).
3
+ //
4
+ // Every tmux subprocess the runner spawns from a HOT PATH — the 2s pane watcher, the 4s
5
+ // busy reconciler, the 1.5s monitorless turn watch, and the per-payload input transport —
6
+ // runs through the shared `SubprocessRunner` created here so it executes as an async
7
+ // `Bun.spawn`, bounded by a hard deadline (SIGTERM → SIGKILL of the whole process group
8
+ // after a grace window), instead of a synchronous `Bun.spawnSync` that pins the runner's
9
+ // single event loop for the entire 10s deadline while a wedged tmux socket hangs (the
10
+ // #1760 class; #1815 findings 1/6/7/10). A blocked tmux call here would otherwise freeze
11
+ // this agent's bus connection, control server, heartbeat, and every timer at once.
12
+ //
13
+ // AT-MOST-ONCE — a property of the backend, not a per-call flag. The offload envelope's
14
+ // `retrySafe` flag is a WorkerPool-only knob (only the CPU worker pool ever re-dispatches
15
+ // after an ambiguous worker death); the SubprocessRunner NEVER re-runs a spawn under any
16
+ // circumstance. So every tmux command routed through here is at-most-once by construction.
17
+ // That is exactly what the SIDE-EFFECTING sites require — a silently re-run `send-keys` or
18
+ // `paste-buffer` would double-type into the live pane — and it is equally fine for the
19
+ // READ-ONLY sites (capture-pane / has-session / display-message), whose next poll tick
20
+ // simply re-reads. No site here is idempotent-and-expensive enough to want retry, so none
21
+ // passes `retrySafe`; the default at-most-once behaviour is correct for all of them.
22
+ import { createSubprocessRunner, type SubprocessResult, type SubprocessRunner } from "@humwick/offload";
23
+ import { tmuxCommand } from "@humwick/sdk/tmux-utils";
24
+ import { SYNC_SUBPROCESS_TIMEOUT_MS } from "../bounded-spawn-sync";
25
+
26
+ let runner: SubprocessRunner | undefined;
27
+
28
+ // Lazily created so merely importing this module spawns nothing. concurrency > 1 keeps the
29
+ // three independent pollers plus an occasional input transport from head-of-line blocking
30
+ // each other behind a single slot, while still bounding fan-out onto the one tmux server a
31
+ // runner process talks to. Each run is bounded by the same 10s deadline the old sync
32
+ // wrapper (`spawnSyncBounded`) used, so timeout semantics are unchanged — only the blocking
33
+ // is gone.
34
+ function tmuxRunner(): SubprocessRunner {
35
+ return (runner ??= createSubprocessRunner({ concurrency: 4, defaultTimeoutMs: SYNC_SUBPROCESS_TIMEOUT_MS }));
36
+ }
37
+
38
+ // Test/teardown seam: cancel in-flight tmux spawns and drop the shared runner. Not wired
39
+ // into adapter shutdown (the runner is process-scoped and shared across adapter instances;
40
+ // a live child is SIGKILLed by the offload module's process `exit` hook regardless), but
41
+ // tests that want a clean slate can await this.
42
+ export async function destroyTmuxRunner(): Promise<void> {
43
+ const current = runner;
44
+ runner = undefined;
45
+ if (current) await current.destroy();
46
+ }
47
+
48
+ /** Per-call bounding for a read that sits inside a caller's OWN deadline loop (#1838 round-3
49
+ * MED): pass the REMAINING outer budget as `timeoutMs` so the subprocess deadline shrinks with
50
+ * the loop, and/or a `signal` the caller aborts at its absolute deadline. Without this each run
51
+ * would get the runner's full ~10s default and blow the caller's (e.g. 500ms) deadline. */
52
+ export interface TmuxReadOpts { signal?: AbortSignal; timeoutMs?: number }
53
+
54
+ async function runTmux(
55
+ taskName: string,
56
+ argv: string[],
57
+ opts: { okExitCodes?: readonly number[] | "any" } & TmuxReadOpts = {},
58
+ ): Promise<SubprocessResult> {
59
+ return tmuxRunner().run(taskName, { command: argv, okExitCodes: opts.okExitCodes }, { signal: opts.signal, timeoutMs: opts.timeoutMs });
60
+ }
61
+
62
+ /**
63
+ * Off-loop `tmux capture-pane` (read-only, idempotent). Throws on a non-zero exit / timeout
64
+ * / abort exactly like the synchronous `captureTmuxPane` it replaces on the hot paths, so
65
+ * the existing `try { … } catch { … }` call sites are unchanged apart from the `await`.
66
+ */
67
+ export async function captureTmuxPaneAsync(sessionName: string, socketName?: string, opts: TmuxReadOpts = {}): Promise<string> {
68
+ const result = await runTmux("tmux-capture-pane", tmuxCapturePaneArgv(sessionName, socketName, TMUX_CAPTURE_SCROLLBACK_LINES), opts);
69
+ return result.stdout;
70
+ }
71
+
72
+ /** Lines of tmux HISTORY the scrollback capture reaches back over, on top of the visible screen. */
73
+ export const TMUX_CAPTURE_SCROLLBACK_LINES = 80;
74
+
75
+ /**
76
+ * The `tmux capture-pane` argv, with the scrollback window as the single explicit knob.
77
+ * `scrollbackLines` omitted/0 ⇒ the VISIBLE SCREEN ONLY (no `-S`). Exported so the two capture
78
+ * scopes are assertable in a unit test — the #2261 regression was exactly a caller reading the
79
+ * wrong one.
80
+ */
81
+ export function tmuxCapturePaneArgv(sessionName: string, socketName?: string, scrollbackLines = 0, styled = false): string[] {
82
+ const style = styled ? ["-e"] : [];
83
+ return scrollbackLines > 0
84
+ ? tmuxCommand(socketName, "capture-pane", "-p", ...style, "-t", sessionName, "-S", `-${scrollbackLines}`)
85
+ : tmuxCommand(socketName, "capture-pane", "-p", ...style, "-t", sessionName);
86
+ }
87
+
88
+ /**
89
+ * Off-loop `tmux capture-pane` of the VISIBLE SCREEN ONLY — no `-S` scrollback window.
90
+ *
91
+ * #2261: the difference matters for anything that reads a repaint as evidence. Claude Code's
92
+ * `/clear` repaints only the visible screen (`ESC[H` + a per-line `ESC[2K`; it never emits the
93
+ * `ESC[3J` that would drop tmux's history), so the pre-clear conversation SURVIVES verbatim in
94
+ * scrollback. A `-S -80` capture therefore still contains the wiped conversation long after the
95
+ * clear committed — which made the clear-commit barrier's "the anchor is gone" test unsatisfiable
96
+ * and turned every successful self-resume clear on a scrolled pane into a false negative. Detectors
97
+ * that ask "did the TUI repaint this away?" must read this window, not the scrollback one.
98
+ */
99
+ export async function captureTmuxVisiblePaneAsync(sessionName: string, socketName?: string, opts: TmuxReadOpts & { styled?: boolean } = {}): Promise<string> {
100
+ const { styled, ...readOpts } = opts;
101
+ // `styled` keeps SGR escapes (`-e`) for readers that must tell dim placeholder text from input (#2664).
102
+ const result = await runTmux("tmux-capture-pane", tmuxCapturePaneArgv(sessionName, socketName, 0, styled), readOpts);
103
+ return result.stdout;
104
+ }
105
+
106
+ /** Off-loop `tmux has-session` outcome. `unreadable` is NOT `absent` — see below. */
107
+ export type TmuxSessionPresence = "present" | "absent" | "unreadable";
108
+
109
+ /**
110
+ * Off-loop `tmux has-session` (read-only, idempotent), as a TRI-STATE (#1838 review HIGH-2).
111
+ *
112
+ * A boolean `present`/`!present` collapses "the session is gone" together with "I could not
113
+ * find out", so a wedged-but-LIVE tmux server (has-session SIGKILLed at its deadline) would
114
+ * read as death and let a watcher emit `offline` / abandon live work. The states are kept
115
+ * distinct here and only the caller decides what a non-`present` reading means:
116
+ * - `present` — tmux exited 0: the session exists.
117
+ * - `absent` — tmux exited 1: the ONLY positive proof of death. `has-session` exits 1
118
+ * both for a missing session and for "no server running", and a dead server
119
+ * genuinely took all its sessions with it, so both are real absence.
120
+ * - `unreadable` — timeout / abort / spawn error / any exit ≥ 2: a fact about the READ, never
121
+ * about the session. The caller MUST hold its current state and retry, never
122
+ * treat this as death.
123
+ * `okExitCodes: [0, 1]` lets the two real answers through without a throw/strike; everything
124
+ * else (including the deadline SIGKILL that bounds a wedged socket) rejects and maps to
125
+ * `unreadable`.
126
+ */
127
+ export async function tmuxSessionPresence(sessionName: string, socketName?: string, opts: TmuxReadOpts = {}): Promise<TmuxSessionPresence> {
128
+ try {
129
+ const result = await runTmux("tmux-has-session", tmuxCommand(socketName, "has-session", "-t", sessionName), { okExitCodes: [0, 1], ...opts });
130
+ return result.exitCode === 0 ? "present" : "absent";
131
+ } catch {
132
+ return "unreadable";
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Off-loop resolve of a tmux session's active-pane pid via `display-message` (read-only,
138
+ * idempotent). Returns the direct pid untouched, or undefined when the session is gone /
139
+ * unreadable / the query times out. The launcher `exec`s claude, so the pane pid IS the claude
140
+ * pid. `okExitCodes: "any"` so a non-zero exit is undefined rather than a throw+strike.
141
+ */
142
+ export async function resolveClaudePanePidAsync(directPid: number | undefined, tmuxSession?: string, tmuxSocket?: string): Promise<number | undefined> {
143
+ if (typeof directPid === "number" && directPid > 0) return directPid;
144
+ if (!tmuxSession) return undefined;
145
+ try {
146
+ const result = await runTmux("tmux-pane-pid", tmuxCommand(tmuxSocket, "display-message", "-p", "-t", tmuxSession, "-F", "#{pane_pid}"), { okExitCodes: "any" });
147
+ if (result.exitCode !== 0) return undefined;
148
+ const pid = Number.parseInt(result.stdout.trim(), 10);
149
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined;
150
+ } catch {
151
+ return undefined;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Off-loop, side-effecting tmux command (send-keys / paste-buffer / delete-buffer). Throws
157
+ * on a non-zero exit / timeout / abort. At-most-once by backend (see the module header): a
158
+ * failure is never silently re-run, so a delivered keystroke can never be double-typed.
159
+ */
160
+ export async function runTmuxInput(taskName: string, argv: string[], signal?: AbortSignal): Promise<SubprocessResult> {
161
+ return runTmux(taskName, argv, { signal });
162
+ }
163
+
164
+ /**
165
+ * Off-loop `tmux load-buffer -b <name> -`, streaming the payload on STDIN (#1838 row 16).
166
+ *
167
+ * This replaces the old `set-buffer -b <name> <text>` whose payload rode in argv — an
168
+ * ARG_MAX exposure for a large delivered message (up to the platform's arg limit, beyond
169
+ * which the spawn fails outright). load-buffer reading from stdin has no such bound.
170
+ *
171
+ * The offload `SubprocessRunner` hard-codes `stdin: "ignore"`, so a stdin-bearing spawn
172
+ * cannot go through it; this is a directly-managed async `Bun.spawn` bounded by the same
173
+ * deadline (SIGKILL on timeout) so it is off-loop and cannot outlive its bound either. It is
174
+ * side-effecting (it mutates a tmux buffer) and, like the runner-backed sends, never retried.
175
+ */
176
+ export async function loadTmuxBufferFromStdin(
177
+ bufferName: string,
178
+ text: string,
179
+ socketName?: string,
180
+ timeoutMs = SYNC_SUBPROCESS_TIMEOUT_MS,
181
+ ): Promise<void> {
182
+ const proc = Bun.spawn(tmuxCommand(socketName, "load-buffer", "-b", bufferName, "-"), {
183
+ stdin: new TextEncoder().encode(text),
184
+ stdout: "ignore",
185
+ stderr: "pipe",
186
+ });
187
+ // Drain stderr concurrently so a chatty failure can never wedge the child on a full pipe.
188
+ // `.catch` keeps this floating drain from surfacing as an unhandled rejection on the success
189
+ // path (where it is never awaited).
190
+ const stderrText = new Response(proc.stderr).text().catch(() => "");
191
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
192
+ const timedOut = await Promise.race([
193
+ proc.exited.then(() => false),
194
+ new Promise<true>((resolve) => { killTimer = setTimeout(() => resolve(true), timeoutMs); }),
195
+ ]);
196
+ if (killTimer) clearTimeout(killTimer);
197
+ if (timedOut) {
198
+ try { proc.kill("SIGKILL"); } catch { /* already gone */ }
199
+ await proc.exited.catch(() => {});
200
+ throw new Error(`tmux load-buffer timed out after ${timeoutMs}ms`);
201
+ }
202
+ const exitCode = await proc.exited;
203
+ if (exitCode !== 0) {
204
+ const stderr = (await stderrText).trim();
205
+ throw new Error(`tmux load-buffer failed: ${stderr || `exit code ${exitCode}`}`);
206
+ }
207
+ }