@humwick/runner 0.0.0-stage → 0.130.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,548 @@
1
+ import { relayEnv } from "@humwick/sdk/env";
2
+ import { envPipeSource, prepareEnvPipe, providerSecretEnvKeys } from "@humwick/sdk/child-env";
3
+ import { PROVIDER_SCRATCH_ENV_KEYS } from "@humwick/sdk/provider-scratch";
4
+ // Claude tmux transport plumbing (extracted from ./claude for the file-size
5
+ // ratchet, #300/#291): session/socket naming, pane capture, input-ready gating,
6
+ // paste/submit injection, launcher-script materialization, and env-key selection.
7
+ // The adapter (./claude) keeps the lifecycle decisions; this module owns the
8
+ // mechanical tmux interactions they ride on. Public symbols are re-exported from
9
+ // ./claude so existing import paths (including claude.test.ts) keep resolving.
10
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
13
+ import { shellEscape as shellQuote } from "@humwick/sdk/shell-utils";
14
+ import { TMUX_SESSION_OWNER_OPTION, managedTmuxSocketName, renderTmuxSessionTarget, tmuxCommand, tmuxHasSession } from "@humwick/sdk/tmux-utils";
15
+ import { claudeClearCommitted, claudeConversationAnchor, claudePaneIsBusy, claudePaneLooksReady } from "./claude-status-detectors";
16
+ import { ClaudeSubmitFence, ClaudeSubmitTranscriptProbe, claudeSubmitRecoveryConfirmed, submitClaudePromptConfirmed } from "./claude-submit-confirmation";
17
+ import type { ClaudePromptHookExpectation, ClaudePromptHookLedger } from "./claude-prompt-hook";
18
+ import type { ProviderInputObserver } from "../adapter";
19
+ import { logger } from "../logger";
20
+ import { claudeClearSessionVerdict, type ClaudeClearSessionReading } from "./claude-session-probe";
21
+ import { tmuxOwnerIdFromEnv } from "../config";
22
+ import { spawnSyncBounded } from "../bounded-spawn-sync";
23
+ import { launcherGatePathForSession, launcherScriptPathForSession, readOneShotExitCode } from "../session-scratch";
24
+ import { captureTmuxPaneAsync, captureTmuxVisiblePaneAsync, loadTmuxBufferFromStdin, runTmuxInput, tmuxSessionPresence, type TmuxReadOpts, type TmuxSessionPresence } from "./tmux-offload";
25
+
26
+ export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
27
+ export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
28
+ export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
29
+ export const CLAUDE_MAX_INLINE_PROMPT_BYTES = 96 * 1024;
30
+ const TMUX_SOCKET_PATH_SAFE_BYTES = 100;
31
+ const promptSubmitFence = new ClaudeSubmitFence();
32
+ const promptSubmitSessionKey = (session: string, socket?: string) => `${socket ?? "default"}:${session}`;
33
+
34
+ // Claude normally accepts its first user turn as a positional argument. Linux limits one
35
+ // argv element well below a useful large task brief, and tmux paste is not a submission
36
+ // guarantee during onboarding. A transient headless Claude can instead consume the exact
37
+ // assignment from stdin in `-p` mode, preserving its user-message role.
38
+ export function seedClaudeInitialPrompt(args: string[], prompt: string, options: { headless: boolean; lifecycle?: string; scratchDir?: string }): { stdinFile: string; exitCodeFile: string; stdinContents: string } | undefined {
39
+ if (new TextEncoder().encode(prompt).byteLength <= CLAUDE_MAX_INLINE_PROMPT_BYTES) {
40
+ args.push(prompt);
41
+ return undefined;
42
+ }
43
+ if (!options.headless || options.lifecycle !== "transient") {
44
+ throw new Error("oversized Claude initial prompt requires a transient headless launch; refusing a racy or role-changing fallback");
45
+ }
46
+ if (!options.scratchDir) throw new Error("oversized Claude initial prompt requires AGENT_RELAY_SCRATCH_DIR");
47
+ const promptFile = join(options.scratchDir, "claude-initial-assignment.md");
48
+ const exitCodeFile = join(options.scratchDir, "claude-initial-exit-code");
49
+ args.push("-p", "--input-format", "text");
50
+ return { stdinFile: promptFile, exitCodeFile, stdinContents: prompt };
51
+ }
52
+
53
+ /** A missing sentinel means the one-shot process was killed before its shell could report success. */
54
+ export async function claudeOneShotExitStatus(exitCodeFile?: string): Promise<"offline" | "error"> {
55
+ if (!exitCodeFile) return "offline";
56
+ return (await readOneShotExitCode(exitCodeFile)) === "0" ? "offline" : "error";
57
+ }
58
+
59
+ export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
60
+ if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
61
+ return `${prefix}-${instanceId.slice(0, 8)}`;
62
+ }
63
+
64
+ // tmux silently rewrites `.` and `:` in new session names to `_` (a colon also
65
+ // separates the session/window/pane portions of a target). All later `-t` lookups
66
+ // must use the rendered name or `has-session` misses the session tmux just created.
67
+ // Delegates to the shared SDK renderer so the runner and orchestrator (which must match
68
+ // the rendered name when resolving a shutdown target, #1746 RC-1) can never diverge.
69
+ export function tmuxSessionTargetName(sessionName: string): string {
70
+ return renderTmuxSessionTarget(sessionName);
71
+ }
72
+
73
+ export function tmuxSocketName(sessionName: string): string {
74
+ // `tmux -L` puts this value at <TMUX_TMPDIR>/tmux-<uid>/<socket>. Unix
75
+ // socket paths are limited to roughly 104-108 bytes, so a display-oriented
76
+ // session name must never flow into the path unbounded. Hash the complete
77
+ // session identity (including the orchestrator's agent/spawn suffix) into a
78
+ // fixed-size token; the readable session name remains unchanged in tmux/UI.
79
+ const socketName = managedTmuxSocketName(sessionName);
80
+ assertTmuxSocketPathFits(socketName);
81
+ return socketName;
82
+ }
83
+
84
+ // Pre-#1353 socket selector, used only for one-way cleanup when a runner first
85
+ // starts after upgrading. Do not assert its path length: an overlong legacy
86
+ // name is exactly what the new fixed-size selector is designed to escape.
87
+ export function legacyTmuxSocketName(sessionName: string): string {
88
+ return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
89
+ }
90
+
91
+ export function createOrReuseTmuxSession(sessionName: string, socketName: string, tmuxArgs: string[]): boolean {
92
+ const targetSessionName = tmuxSessionTargetName(sessionName);
93
+ spawnSyncBounded(tmuxCommand(socketName, "kill-session", "-t", targetSessionName), {
94
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
95
+ });
96
+ // Upgrade cleanup: a pre-#1353 runner used the readable session name as its
97
+ // selector. Best-effort removal prevents a legacy pane surviving beside its
98
+ // replacement; an overlong legacy selector can fail here harmlessly.
99
+ spawnSyncBounded(tmuxCommand(legacyTmuxSocketName(sessionName), "kill-session", "-t", targetSessionName), {
100
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
101
+ });
102
+ const result = spawnSyncBounded(tmuxCommand(socketName, ...tmuxArgs), {
103
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
104
+ });
105
+ const stderr = result.stderr.toString().trim();
106
+ // A restart can race a still-live predecessor. Reuse the uniquely named,
107
+ // confirmed session rather than failing the healthy agent on "duplicate".
108
+ const reusable = tmuxSessionCreationFailureIsReusable(stderr, targetSessionName, socketName);
109
+ if (result.exitCode !== 0 && !reusable) {
110
+ throw new Error(`tmux session creation failed: ${stderr || (result.signalCode ? `signal ${result.signalCode}` : `exit code ${result.exitCode}`)}`);
111
+ }
112
+ if (result.exitCode === 0) promptSubmitFence.clear(promptSubmitSessionKey(targetSessionName, socketName));
113
+ // #1514 — stamp the owning orchestrator's state-home id (passed to this runner via
114
+ // env) onto the session so the orchestrator's wedged-session reaper reaps ONLY the
115
+ // sessions it positively owns, never a co-tenant orchestrator's on the same host.
116
+ setTmuxSessionOwner(targetSessionName, tmuxOwnerIdFromEnv(), socketName);
117
+ return result.exitCode === 0;
118
+ }
119
+
120
+ export async function createTmuxSessionWithEnv(sessionName: string, socketName: string, args: string[], script: string, spawnEnv: Record<string, string>, providerEnv: Record<string, string>, gateFifo?: string, provider = "claude"): Promise<void> {
121
+ const keys = tmuxEnvKeys(spawnEnv, providerEnv, provider);
122
+ const env = Object.fromEntries(keys.filter((key) => relayEnv(spawnEnv, key) !== undefined).map((key) => [key, relayEnv(spawnEnv, key)!]));
123
+ const pipe = await prepareEnvPipe(script, env);
124
+ try {
125
+ if (createOrReuseTmuxSession(sessionName, socketName, args)) await pipe.sent();
126
+ } catch (error) {
127
+ await runTmuxInput("tmux-launch-cleanup", tmuxCommand(socketName, "kill-session", "-t", tmuxSessionTargetName(sessionName))).catch(() => {});
128
+ if (gateFifo) await releaseLauncherGate(gateFifo);
129
+ throw error;
130
+ } finally { await pipe.cleanup(); }
131
+ }
132
+
133
+ export function tmuxSessionCreationFailureIsReusable(
134
+ stderr: string,
135
+ sessionName: string,
136
+ socketName: string,
137
+ hasSession: (session: string, socket?: string) => boolean = tmuxHasSession,
138
+ ): boolean {
139
+ return /duplicate session/i.test(stderr)
140
+ && hasSession(tmuxSessionTargetName(sessionName), socketName);
141
+ }
142
+
143
+ // #1514 — stamp the owning orchestrator's state-home id onto the tmux session as a
144
+ // user-option. The orchestrator's wedged-session reaper reads it back and reaps ONLY
145
+ // sessions whose owner id matches its own, so it can never reap a session belonging
146
+ // to a different orchestrator that happens to share the UID + tmux prefix on this
147
+ // host. Best-effort: a failure just leaves the session untagged, which the reaper
148
+ // treats as "not positively owned" and skips (fail-safe).
149
+ export function setTmuxSessionOwner(sessionName: string, ownerId: string | undefined, socketName?: string): void {
150
+ if (!ownerId) return;
151
+ spawnSyncBounded(tmuxCommand(socketName, "set-option", "-t", sessionName, TMUX_SESSION_OWNER_OPTION, ownerId), {
152
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
153
+ });
154
+ }
155
+
156
+ export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
157
+ return join(socketDir, socketName);
158
+ }
159
+
160
+ export function assertTmuxSocketPathFits(socketName: string, socketDir = defaultTmuxSocketDir()): void {
161
+ const path = tmuxSocketPath(socketName, socketDir);
162
+ const bytes = Buffer.byteLength(path);
163
+ if (bytes <= TMUX_SOCKET_PATH_SAFE_BYTES) return;
164
+ throw new Error(`tmux socket directory is too long (socket path is ${bytes} bytes; must be <= ${TMUX_SOCKET_PATH_SAFE_BYTES}); shorten TMUX_TMPDIR`);
165
+ }
166
+
167
+ // A single failed `has-session` can be transient (and historically was at the
168
+ // Unix socket path boundary). Call after an initial miss and only declare the
169
+ // session gone if a delayed confirmation misses too.
170
+ export async function confirmTmuxSessionGone(
171
+ sessionName: string,
172
+ socketName?: string,
173
+ delayMs = 250,
174
+ // #1838 row 12 — off the event loop via the tri-state `tmuxSessionPresence` (#1838 review
175
+ // HIGH-2). Death is confirmed ONLY when BOTH the immediate and the delayed read are the
176
+ // positive `absent` (tmux exited 1); a `present` OR an `unreadable` (timeout/abort on a
177
+ // wedged-but-live server) at either point means NOT gone, so a slow read can never be
178
+ // mistaken for a dead session. Unit tests inject their own presence override.
179
+ presence: (session: string, socket?: string) => TmuxSessionPresence | Promise<TmuxSessionPresence> = tmuxSessionPresence,
180
+ ): Promise<boolean> {
181
+ if ((await presence(sessionName, socketName)) !== "absent") return false;
182
+ await Bun.sleep(delayMs);
183
+ return (await presence(sessionName, socketName)) === "absent";
184
+ }
185
+
186
+ function defaultTmuxSocketDir(): string {
187
+ const base = process.env.TMUX_TMPDIR || "/tmp";
188
+ const uid = typeof process.getuid === "function" ? process.getuid() : "user";
189
+ return join(base, `tmux-${uid}`);
190
+ }
191
+
192
+ export function captureTmuxPane(sessionName: string, socketName?: string): string {
193
+ const result = spawnSyncBounded(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
194
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
195
+ });
196
+ if (result.exitCode !== 0) {
197
+ const stderr = result.stderr.toString().trim();
198
+ throw new Error(`tmux capture-pane failed: ${stderr || (result.signalCode ? `signal ${result.signalCode}` : `exit code ${result.exitCode}`)}`);
199
+ }
200
+ return result.stdout.toString();
201
+ }
202
+
203
+ // #1514's exit-dialog confirmation used to live here as
204
+ // `confirmClaudeExitDialogIfPresent(sessionName, socketName)` — an Enter writer that
205
+ // any caller holding a session name could invoke. #1647 removed it: taking a session
206
+ // name meant nothing in the type system stopped a pane detector from calling it, and
207
+ // the production kill was exactly that Enter answering an "Exit anyway" modal on a
208
+ // live pane. It now lives in ./claude-exit, where both it and the `/exit` writer take
209
+ // an unforgeable one-shot ClaudeExitGrant instead.
210
+
211
+ // #1838 review BLOCKER + round-3 MED — this readiness poller runs on the hot delivery path
212
+ // (every deliver / deliverInitialPrompt / monitor self-resume settle) and used to call the
213
+ // SYNCHRONOUS tmuxHasSession + captureTmuxPane every 200ms; a single wedged tmux call froze the
214
+ // whole runner event loop for the full 10s deadline. Both reads now go OFF the loop and use the
215
+ // tri-state presence (only a positive `absent` ends the wait with the session-exited error; an
216
+ // `unreadable`/transient read just retries).
217
+ //
218
+ // Round-3 MED: the deadline is now ACTUALLY absolute. Checking Date.now() only BETWEEN calls was
219
+ // not enough — each subprocess still got the runner's ~10s default deadline + kill grace, so a
220
+ // waitForClaudeInputReady(...,500) could hold the serialized provider-input ticket ~12s. An
221
+ // AbortController fires at the outer deadline AND the REMAINING budget is threaded into each read
222
+ // as its own timeout, so no single read can outlive the caller's deadline (bar the irreducible
223
+ // SIGKILL grace).
224
+ export async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
225
+ const deadline = Date.now() + timeoutMs;
226
+ const controller = new AbortController();
227
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
228
+ abortTimer.unref?.();
229
+ try {
230
+ for (;;) {
231
+ const remaining = deadline - Date.now();
232
+ if (remaining <= 0) break;
233
+ const readOpts: TmuxReadOpts = { signal: controller.signal, timeoutMs: remaining };
234
+ const presence = await tmuxSessionPresence(sessionName, socketName, readOpts);
235
+ if (presence === "absent") throw new Error("tmux session exited before Claude became ready");
236
+ if (presence === "present") {
237
+ try {
238
+ if (claudePaneLooksReady(await captureTmuxVisiblePaneAsync(sessionName, socketName, readOpts))) return;
239
+ } catch { /* transient/aborted capture — retry until the deadline rather than fail delivery */ }
240
+ }
241
+ await Bun.sleep(Math.min(200, Math.max(0, deadline - Date.now())));
242
+ }
243
+ throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
244
+ } finally {
245
+ clearTimeout(abortTimer);
246
+ }
247
+ }
248
+
249
+ export const CLAUDE_CLEAR_COMMIT_POLL_MS = 200;
250
+
251
+ /** #2850 — consecutive idle+ready polls (~1s) a vacuous clear needs when the probe can't attest. */
252
+ export const CLAUDE_VACUOUS_CLEAR_READY_POLLS = 5;
253
+
254
+ /**
255
+ * #2261 — the capture scope the clear-commit barrier reads, pinned as a named export so the
256
+ * invariant is assertable. It MUST be the visible-screen capture: `/clear` repaints only the
257
+ * visible screen (no `ESC[3J`), so a scrollback capture keeps returning the wiped conversation —
258
+ * and with it the anchor — for the entire timeout, making a committed clear unconfirmable.
259
+ */
260
+ export const CLAUDE_CLEAR_COMMIT_CAPTURE = captureTmuxVisiblePaneAsync;
261
+
262
+ // Injectable seams so the polling loop (which otherwise only talks to a live tmux server) is
263
+ // unit-testable — same pattern as confirmTmuxSessionGone's `hasSession` override.
264
+ export interface ClaudeClearCommitDeps {
265
+ capture?: (sessionName: string, socketName?: string, opts?: TmuxReadOpts) => string | Promise<string>;
266
+ presence?: (sessionName: string, socketName?: string, opts?: TmuxReadOpts) => TmuxSessionPresence | Promise<TmuxSessionPresence>;
267
+ sleep?: (ms: number) => Promise<unknown>;
268
+ /** #2261 — the probe reading taken immediately BEFORE the `/clear` submit (null = unavailable). */
269
+ preClearSession?: ClaudeClearSessionReading | null;
270
+ /** #2261 — re-read it each poll, pid-pinned by the caller. Absent → anchor-only (legacy). */
271
+ readSession?: () => Promise<ClaudeClearSessionReading | null>;
272
+ /** #2849 — true only when `preClearPane` is a successful capture (not the "" fallback
273
+ * left by a throw). Lets a provably-empty pre-clear pane confirm via idle+ready. */
274
+ preClearCaptured?: boolean;
275
+ }
276
+
277
+ // #1588 — CLEAR-AWARE settle barrier for the self-resume clear+inject coupling. Unlike
278
+ // waitForClaudeInputReady (which returns on the first persistent-footer match — present BOTH
279
+ // before and after `/clear`, the F2 race), this requires POSITIVE evidence the `/clear` actually
280
+ // committed: it snapshots a distinctive pre-clear conversation anchor and only reports success
281
+ // once the pane is idle, input-ready, AND that anchor is gone (the loaded conversation was wiped).
282
+ // Returns a BOOLEAN rather than throwing: `true` = clear positively confirmed committed; `false` =
283
+ // timed out / session vanished / capture failed / no anchor derivable (uncertain). The caller MUST
284
+ // treat `false` as "not confirmed" and report continuationDelivered:false so the #1023 backstop
285
+ // re-injects — never as a wipe. A settle timeout is therefore surfaced, not swallowed.
286
+ export async function waitForClaudeClearCommitted(
287
+ sessionName: string,
288
+ preClearPane: string,
289
+ timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS,
290
+ socketName?: string,
291
+ deps: ClaudeClearCommitDeps = {},
292
+ ): Promise<boolean> {
293
+ // #1838 review — same off-loop + tri-state + ABSOLUTE-deadline treatment as
294
+ // waitForClaudeInputReady (this poller also ran sync captureTmuxPane on the clear path, and had
295
+ // the same between-calls-only deadline, round-3 MED). A positive `absent` returns false (clear
296
+ // not confirmed → the #1023 backstop re-injects, the safe outcome); an `unreadable` read or a
297
+ // transient capture failure retries until the deadline instead of blocking or giving up early.
298
+ // #2261 — the VISIBLE screen, never the `-S -80` scrollback window: `/clear` repaints only the
299
+ // visible screen, so a scrollback capture keeps returning the wiped conversation (and with it the
300
+ // anchor) for the whole timeout. That single mismatch made a committed clear unconfirmable on any
301
+ // pane with history — proven live: the clear-inject at 22:15:10.185Z minted its new Claude
302
+ // session 112ms later (new transcript file), yet the barrier polled the full 10s and returned
303
+ // false. `preClearPane` must be a visible-screen capture too, so the anchor is a line the clear
304
+ // can actually repaint away.
305
+ const capture = deps.capture ?? CLAUDE_CLEAR_COMMIT_CAPTURE;
306
+ const presence = deps.presence ?? tmuxSessionPresence;
307
+ const sleep = deps.sleep ?? Bun.sleep;
308
+ const anchor = claudeConversationAnchor(preClearPane);
309
+ const { preClearSession, readSession } = deps;
310
+ // #2849 — a positively-captured pane with no anchor held no conversation, so the
311
+ // clear is vacuous. A failed capture ("") also yields no anchor but must stay
312
+ // fail-closed (preClearCaptured false). Confirmation still needs the settle below.
313
+ const preClearEmpty = deps.preClearCaptured === true && anchor === undefined;
314
+ // #2849r1/#2850 — with no probe verdict a ready pane may predate the commit (#1588),
315
+ // so a vacuous clear needs a CLAUDE_VACUOUS_CLEAR_READY_POLLS streak. A failed capture,
316
+ // a busy/non-ready pane or an `unchanged` probe reading resets it.
317
+ let readyStreak = 0;
318
+ // #2261 — the new session is minted ~112ms BEFORE the TUI finishes repainting (measured on the
319
+ // live repro), and inside that window an un-cleared pane still reads non-busy + input-ready. So
320
+ // a rotation only counts once it has been observed on TWO CONSECUTIVE polls, which lands the
321
+ // confirmation after the repaint rather than inside the #1588 injection window. One poll
322
+ // interval (200ms) out of a 10s budget.
323
+ let rotationSeen = false;
324
+ const deadline = Date.now() + timeoutMs;
325
+ const controller = new AbortController();
326
+ const abortTimer = setTimeout(() => controller.abort(), timeoutMs);
327
+ abortTimer.unref?.();
328
+ try {
329
+ for (;;) {
330
+ const remaining = deadline - Date.now();
331
+ if (remaining <= 0) break;
332
+ const readOpts: TmuxReadOpts = { signal: controller.signal, timeoutMs: remaining };
333
+ if ((await presence(sessionName, socketName, readOpts)) === "absent") return false;
334
+ let pane: string;
335
+ try {
336
+ pane = await capture(sessionName, socketName, readOpts);
337
+ } catch {
338
+ readyStreak = 0;
339
+ // #1838 round-4 MED — clamp the poll sleep to the remaining budget so a wait started
340
+ // with < CLAUDE_CLEAR_COMMIT_POLL_MS left can't overshoot the absolute deadline.
341
+ await sleep(Math.min(CLAUDE_CLEAR_COMMIT_POLL_MS, Math.max(0, deadline - Date.now())));
342
+ continue;
343
+ }
344
+ // A throw/absent reading yields the `unknown` verdict, so the poll falls back to the anchor
345
+ // test (and, failing that, to the backstop) exactly as it did before the probe existed.
346
+ const verdict = claudeClearSessionVerdict(preClearSession, readSession ? await readSession().catch(() => null) : null);
347
+ const settled = verdict === "rotated" && !rotationSeen ? "unknown" : verdict;
348
+ rotationSeen = verdict === "rotated";
349
+ readyStreak = verdict !== "unchanged" && !claudePaneIsBusy(pane) && claudePaneLooksReady(pane) ? readyStreak + 1 : 0;
350
+ const settledReady = readyStreak >= CLAUDE_VACUOUS_CLEAR_READY_POLLS;
351
+ if (claudeClearCommitted(anchor, pane, { session: settled, preClearEmpty, settledReady })) return true;
352
+ // #1838 round-4 MED — same clamp on the steady-state poll.
353
+ await sleep(Math.min(CLAUDE_CLEAR_COMMIT_POLL_MS, Math.max(0, deadline - Date.now())));
354
+ }
355
+ return false;
356
+ } finally {
357
+ clearTimeout(abortTimer);
358
+ }
359
+ }
360
+
361
+ // #1838 row 16 — the provider input transport (up to 50 spawns per delivered payload).
362
+ // Wave 2a moves every tmux subprocess here OFF the event loop via the offload runner, and the
363
+ // payload rides on STDIN (`load-buffer -`) rather than in argv.
364
+ //
365
+ // The old `set-buffer -b <name> <text>` put the whole delivered message in argv — an
366
+ // ARG_MAX exposure: a large enough message (Claude briefs are routinely multi-KB and can be
367
+ // far larger) overflows the platform's argument limit and the spawn fails outright. tmux
368
+ // `load-buffer` reading from stdin has no such bound, so the buffer load is unconditionally
369
+ // safe now. Every step is side-effecting (it mutates a buffer or types into the pane) and
370
+ // at-most-once — see tmux-offload.ts's header for why a re-run could never double-send.
371
+ //
372
+ // User prompts opt into visible-composer confirmation. Context commands retain their own
373
+ // command-specific commit barriers; the tmux transport still sends those once.
374
+ async function deleteTmuxBuffer(bufferName: string, socketName?: string): Promise<void> {
375
+ // Best-effort: a failed delete must never fail (or mask) the delivery outcome. #1838 review
376
+ // LOW — always runs, so a buffer loaded-but-not-cleaned (e.g. a paste timeout) can't leak.
377
+ await runTmuxInput("tmux-delete-buffer", tmuxCommand(socketName, "delete-buffer", "-b", bufferName)).catch(() => {});
378
+ }
379
+
380
+ /** Claude user prompts need one bracketed paste with LF intact; slash commands stay literal. */
381
+ export function claudePasteBufferCommand(sessionName: string, bufferName: string, socketName?: string, confirmSubmit = false): string[] {
382
+ return tmuxCommand(socketName, "paste-buffer", ...(confirmSubmit ? ["-p", "-r"] : []), "-b", bufferName, "-t", sessionName);
383
+ }
384
+
385
+ /** One event-driven recovery probe; no polling loop runs while the provider is blocked. */
386
+ export async function confirmClaudeSubmitRecovery(sessionName: string, socketName?: string, turnStarted = false): Promise<boolean> {
387
+ return promptSubmitFence.recover(promptSubmitSessionKey(sessionName, socketName), turnStarted);
388
+ }
389
+
390
+ export function releaseClaudeSubmitFence(sessionName: string, socketName?: string): void {
391
+ promptSubmitFence.clear(promptSubmitSessionKey(sessionName, socketName));
392
+ }
393
+
394
+ export async function submitTextToTmux(sessionName: string, text: string, socketName?: string, options: { confirmSubmit?: boolean; transcriptPath?: string; resolveTranscriptPath?: () => Promise<string | undefined>; promptHooks?: ClaudePromptHookLedger; imagePaths?: readonly string[]; inputObserver?: ProviderInputObserver } = {}): Promise<void> {
395
+ const bufferName = `agent-relay-${crypto.randomUUID()}`;
396
+ const paste = async () => {
397
+ try {
398
+ // Load via stdin so large prompts never hit the platform argv limit.
399
+ await loadTmuxBufferFromStdin(bufferName, text, socketName);
400
+ // tmux otherwise converts every LF to CR, which can submit each paragraph
401
+ // of a dashboard prompt as a separate turn. Claude requests bracketed paste;
402
+ // preserve LF and wrap user prompts so its editor receives one paste event.
403
+ await runTmuxInput("tmux-paste-buffer", claudePasteBufferCommand(sessionName, bufferName, socketName, options.confirmSubmit));
404
+ } finally {
405
+ await deleteTmuxBuffer(bufferName, socketName);
406
+ }
407
+ };
408
+ if (options.confirmSubmit) {
409
+ // Hook mode only on evidence: a stamped hook has reached the runner from this Claude.
410
+ const hooks = options.promptHooks?.hookPathVerified ? options.promptHooks : undefined;
411
+ if (options.promptHooks && !hooks) logger.warn("claude", `no stamped hook seen yet; confirming the submit by the composer (${sessionName})`);
412
+ // The composer (plus the transcript) confirms hookless submits and recovers any failed one.
413
+ const transcript = (options.transcriptPath || options.resolveTranscriptPath)
414
+ ? new ClaudeSubmitTranscriptProbe(options.resolveTranscriptPath ?? (async () => options.transcriptPath)) : undefined;
415
+ await transcript?.start(options.transcriptPath);
416
+ let expectation: ClaudePromptHookExpectation | undefined;
417
+ let offeredAt: number | undefined;
418
+ const confirmConsumption = async () => {
419
+ const consumed = expectation?.accepted() || (offeredAt !== undefined && await transcript?.confirms(text, offeredAt, options.imagePaths));
420
+ if (consumed) options.inputObserver?.consumed();
421
+ return consumed === true;
422
+ };
423
+ const startedAt = Date.now();
424
+ await promptSubmitFence.run(promptSubmitSessionKey(sessionName, socketName), async (markPasteStarted) => {
425
+ expectation = hooks?.expect(text, options.imagePaths);
426
+ let entered = false;
427
+ const mode = await submitClaudePromptConfirmed({
428
+ ...(expectation ? { hook: expectation } : {}),
429
+ paste: async () => { markPasteStarted(); await paste(); },
430
+ readPane: (timeoutMs) => captureTmuxVisiblePaneAsync(sessionName, socketName, { timeoutMs, styled: true }),
431
+ sendEnter: async () => {
432
+ if (!entered) { offeredAt = Date.now(); options.inputObserver?.offered(offeredAt, Boolean(hooks || transcript)); }
433
+ entered = true;
434
+ await runTmuxInput("tmux-send-keys-submit", tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY));
435
+ },
436
+ cancelCopyMode: async () => {
437
+ await runTmuxInput("tmux-cancel-copy-mode", tmuxCommand(socketName, "send-keys", "-t", sessionName, "-X", "cancel")).catch(() => {});
438
+ },
439
+ delayBeforeFirstEnterMs: CLAUDE_TMUX_SUBMIT_DELAY_MS,
440
+ onRetry: (attempt) => logger.warn("claude", `Claude input still in composer after Enter; retrying submit key ${attempt}/3 (${sessionName})`),
441
+ }).catch((error) => {
442
+ if (hooks && entered && !expectation!.accepted()) {
443
+ hooks.hookPathLost();
444
+ logger.warn("claude", `no UserPromptSubmit hook after Enter; hook path unverified until the next stamped hook (${sessionName})`);
445
+ }
446
+ throw error;
447
+ });
448
+ await confirmConsumption();
449
+ expectation?.release();
450
+ logger.info("claude", `Claude prompt submit confirmed via ${mode} in ${Date.now() - startedAt}ms (${sessionName})`);
451
+ }, async (turnStarted) => await confirmConsumption() || claudeSubmitRecoveryConfirmed({
452
+ readPane: (timeoutMs) => captureTmuxVisiblePaneAsync(sessionName, socketName, { timeoutMs, styled: true }),
453
+ ...(transcript ? { readNewUserPrompts: () => transcript.poll() } : {}),
454
+ paneShowsTurnStarted: claudePaneIsBusy,
455
+ }, turnStarted));
456
+ return;
457
+ }
458
+ // Submit the pasted input with a SINGLE send-keys C-m: one send, throw-on-failure, no retry —
459
+ // delivery-acknowledgment semantics identical to main (see the header note + #1844).
460
+ await paste();
461
+ await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
462
+ options.inputObserver?.offered(Date.now(), false);
463
+ await runTmuxInput("tmux-send-keys-submit", tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY));
464
+ }
465
+
466
+ // The script keeps the provider command out of tmux argv. Environment values
467
+ // arrive through a private FIFO; only the recovery identity is written here.
468
+ // `gateFifo`, when given, is read from (blocking) before the real command execs
469
+ // (#1317): the caller creates the FIFO before the tmux session exists, attaches
470
+ // `pipe-pane` once the pane is up, then writes to the FIFO to release the gate.
471
+ // That guarantees no provider output can be produced before pipe-pane is capturing
472
+ // it — a process that dies in milliseconds previously produced a 0-byte log because
473
+ // pipe-pane was attached in a separate call *after* the session (and its command)
474
+ // had already started. If the FIFO doesn't exist (e.g. mkfifo unavailable), the
475
+ // redirection fails, bash prints a warning and continues straight to `exec` — same
476
+ // racy-but-working behavior as before this fix, not a hard failure.
477
+ export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string, gateFifo?: string, stdinFile?: string, exitCodeFile?: string, unsetKeys: string[] = []): string {
478
+ const scriptPath = launcherScriptPathForSession(sessionName);
479
+ const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
480
+ const exports = Object.entries(env)
481
+ .filter(([key]) => key === "AGENT_RELAY_ID")
482
+ .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
483
+ .join("\n");
484
+ // #2513 — exports only ADD to the tmux server's inherited environment; a key that must
485
+ // not reach the pane (an ambient credential a second-subscription launch must shed)
486
+ // needs an explicit unset, absence from `env` is not enough.
487
+ const unsets = unsetKeys.map((key) => `unset ${shellEnvKey(key)}`).join("\n");
488
+ const gate = gateFifo ? `read -r _ < ${shellQuote(gateFifo)} || true\n` : "";
489
+ const stdin = stdinFile ? `exec < ${shellQuote(stdinFile)}\n` : "";
490
+ const command = exitCodeFile ? `${shellCmd}\nexit_code=$?\nprintf '%s' "$exit_code" > ${shellQuote(exitCodeFile)}\nexit "$exit_code"` : `exec ${shellCmd}`;
491
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}${Object.keys(env).length ? `${envPipeSource()}\n` : ""}${unsets ? `${unsets}\n` : ""}rm -f -- "$0"\n${gate}${stdin}${command}\n`, { mode: 0o700 });
492
+ return scriptPath;
493
+ }
494
+
495
+ // Create the gate FIFO before the tmux session starts. Best-effort: if `mkfifo`
496
+ // is unavailable, the launcher script's `read < fifo` simply fails-and-continues
497
+ // (see writeLauncherScript), so callers don't need to branch on this return value.
498
+ export function createLauncherGate(gateFifo: string): void {
499
+ try { rmSync(gateFifo, { force: true }); } catch {}
500
+ spawnSyncBounded(["mkfifo", "-m", "600", gateFifo], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
501
+ }
502
+
503
+ // Release a launcher gate created by `createLauncherGate`, unblocking the
504
+ // launcher script's `read`. Opening a FIFO for writing blocks until a reader is
505
+ // present, so this runs the write in a short-lived subprocess with a timeout
506
+ // guard: if nothing ever reads the FIFO (session/launcher failed to start), we
507
+ // give up rather than hang the caller forever.
508
+ export async function releaseLauncherGate(gateFifo: string, timeoutMs = 5_000): Promise<void> {
509
+ try {
510
+ const proc = Bun.spawn(["sh", "-c", `printf x > ${shellQuote(gateFifo)}`], {
511
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
512
+ });
513
+ const timedOut = await Promise.race([
514
+ proc.exited.then(() => false),
515
+ Bun.sleep(timeoutMs).then(() => true),
516
+ ]);
517
+ if (timedOut) {
518
+ try { proc.kill(); } catch {}
519
+ }
520
+ } catch {
521
+ // best-effort — a stuck launcher gate is no worse than the pre-#1317 race.
522
+ } finally {
523
+ try { rmSync(gateFifo, { force: true }); } catch {}
524
+ }
525
+ }
526
+
527
+ function shellEnvKey(key: string): string {
528
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
529
+ throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
530
+ }
531
+
532
+ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>, provider = "claude"): string[] {
533
+ // #1222 — PATH is always carried through, not just AGENT_RELAY_/CLAUDE_ vars, so the
534
+ // launcher script's exported session PATH (built by buildSpawnArgs) reaches
535
+ // the tmux pane instead of whatever ambient PATH the tmux server happened to inherit.
536
+ // #3084 — likewise the agent's private TMPDIR/TMP/TEMP: without them the pane got the tmux
537
+ // server's host-shared TMPDIR while only CLAUDE_CODE_TMPDIR was per-agent.
538
+ const keys = new Set<string>(["PATH", ...PROVIDER_SCRATCH_ENV_KEYS, ...providerSecretEnvKeys(provider)]);
539
+ for (const key of Object.keys(env)) {
540
+ if ((key.startsWith("AGENT_RELAY_") || key.startsWith("HUMWICK_")) || key.startsWith("CLAUDE_")) {
541
+ keys.add(key);
542
+ }
543
+ }
544
+ for (const key of Object.keys(providerEnv)) {
545
+ keys.add(key);
546
+ }
547
+ return [...keys].sort();
548
+ }