agent-relay-runner 0.129.6 → 0.129.9

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.129.6",
3
+ "version": "0.129.9",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,9 @@
22
22
  "directory": "runner"
23
23
  },
24
24
  "dependencies": {
25
- "agent-relay-providers": "0.104.4",
26
- "agent-relay-sdk": "0.2.125",
27
- "callmux": "0.23.0"
25
+ "agent-relay-providers": "0.104.5",
26
+ "agent-relay-sdk": "0.2.127",
27
+ "callmux": "0.24.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.129.6",
4
+ "version": "0.129.9",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -56,8 +56,8 @@ var RUNNER_PROMPT_TEMPLATE_DEFAULTS = [
56
56
  category: "runner",
57
57
  title: "Runner Workspace Lifecycle Note",
58
58
  description: "Lifecycle note injected by the runner for isolated workspaces.",
59
- template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
60
- defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
59
+ template: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
60
+ defaultTemplate: lines("[agent-relay] Isolated workspace: you are in a git worktree on branch {{branch}}, based on {{baseRef}} \u2014 NOT the main checkout. Other agents may work in parallel and land to {{baseRef}}, so {{baseRef}} will move under you. That is expected; don't fight it.", "While iterating, self-validate by running only the tests affected by your change \u2014 use your project's affected-test command if it has one; the full test gate still runs automatically at delivery, so scoping down while you iterate is safe.", "Do NOT push this branch yourself \u2014 not with `git push`, not with `tl push` or any other push wrapper, and do not manually rebase or merge. A steward may be auto-rebasing this branch in the background; pushing concurrently races it and can leave the worktree mid-rebase. Just commit your work here. When the task is done, call `relay_task_deliver` with disposition `complete`, a summary, commits, and passing `typecheck` + `test` gates when the diff touches code/test/config paths. Relay records the typed delivery on the Task, skips code gates for empty/docs-only diffs, then rebases onto the latest {{baseRef}}, lands your work, and pushes for you.", "After `relay_task_deliver` accepts a complete delivery, the workspace status becomes `ready` \u2014 this is the NORMAL, healthy hand-off state, NOT an escalation or a stall. Relay auto-merges clean rebases roughly every 2 minutes; a steward agent is spawned (after a short delay) ONLY if it can't land deterministically, so seeing no steward means it's working, not stuck. Stop your turn after delivery and go idle; do NOT wait or poll the steward queue. Relay wakes you with `landed-success` when your branch lands and refreshes, or `landed-failure` if landing fatally fails. On success you'll be on a fresh rebased branch whose name gains a `--N` suffix \u2014 expected, keep working there. Never `cd` into the main checkout, and never merge/push/resolve conflicts yourself \u2014 Relay and the steward own all of that. `agent-relay workspace status` anytime shows your current state and the exact next step."),
61
61
  variables: ["branch", "baseRef"]
62
62
  },
63
63
  {
@@ -20,7 +20,11 @@ agent-relay /pair codex "Debug flaky tests"
20
20
  agent-relay /pair status
21
21
  agent-relay /pair accept PAIR_ID
22
22
  agent-relay /pair reject PAIR_ID
23
- agent-relay /pair send PAIR_ID "What do you see?"
23
+ agent-relay /pair send PAIR_ID --stdin <<'EOF'
24
+ What do you see?
25
+ EOF
24
26
  ```
25
27
 
26
28
  Report the command output briefly. If the CLI cannot detect this session's agent id, rerun with `--agent AGENT_ID` or `--from AGENT_ID` using the Agent Relay ID shown in session context.
29
+
30
+ Always pass the message body via a quoted heredoc into `--stdin` (or `--body-file PATH`), never inline in the shell command: backticks / `$(...)` in a body are otherwise command-substituted by your shell before `agent-relay` runs (#1263).
@@ -19,7 +19,11 @@ agent-relay /pair codex "Debug flaky tests"
19
19
  agent-relay /pair status
20
20
  agent-relay /pair accept PAIR_ID
21
21
  agent-relay /pair reject PAIR_ID
22
- agent-relay /pair send PAIR_ID "What do you see?"
22
+ agent-relay /pair send PAIR_ID --stdin <<'EOF'
23
+ What do you see?
24
+ EOF
23
25
  ```
24
26
 
25
27
  Report the command output briefly. If the CLI cannot detect this session's agent id, rerun with `--agent AGENT_ID` or `--from AGENT_ID` using the Agent Relay ID shown in session context.
28
+
29
+ Always pass the message body via a quoted heredoc into `--stdin` (or `--body-file PATH`), never inline in the shell command: backticks / `$(...)` in a body are otherwise command-substituted by your shell before `agent-relay` runs (#1263).
package/src/adapter.ts CHANGED
@@ -256,6 +256,9 @@ export interface ProviderAdapter {
256
256
  // Interrupt the in-flight turn without ending the session (ESC for Claude's
257
257
  // tmux pane, turn/interrupt for the Codex app-server). Provider-independent at
258
258
  // the runner boundary; each adapter does what its provider actually supports.
259
+ // Contract: abort the turn, never drain it and never attempt Relay-side rollback.
260
+ // Tool side effects already applied (including partial file writes) remain for the
261
+ // agent to reconcile. Redirect waits for provider idle before starting a clean turn.
259
262
  interrupt?(process: ManagedProcess): Promise<Record<string, unknown> | void>;
260
263
  // Out-of-band activity probe for the busy-state reconciler: returns the real
261
264
  // provider activity when the runner's claim state may have gone stale (e.g. the
@@ -277,7 +280,11 @@ export interface ProviderAdapter {
277
280
  publishPassiveTelemetry?(): Promise<void>;
278
281
  // `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
279
282
  // first (cold-start) delivery vs. a fast re-attempt after a ready signal (#329).
280
- deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void>;
283
+ // `options.isInitial` (default true) tells the adapter whether this delivery is the
284
+ // agent's genuine first turn — the runner passes false from the dashboard
285
+ // prompt-injection / continue paths (#1228) so a mid-session nudge isn't mislabeled
286
+ // as the initial prompt.
287
+ deliverInitialPrompt?(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number; isInitial?: boolean }): Promise<void>;
281
288
  // When true, the adapter seeds the spawn-time initial prompt as a launch argument
282
289
  // (Claude's positional `claude "<prompt>"`), so it's already delivered the instant the
283
290
  // session starts. The runner must then NOT also deliver it post-launch via
@@ -4,17 +4,20 @@
4
4
  // The adapter (./claude) keeps the lifecycle decisions; this module owns the
5
5
  // mechanical tmux interactions they ride on. Public symbols are re-exported from
6
6
  // ./claude so existing import paths (including claude.test.ts) keep resolving.
7
- import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { createHash } from "node:crypto";
8
+ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
8
9
  import { dirname, join } from "node:path";
9
10
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
10
11
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
11
12
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
12
13
  import { claudePaneLooksReady } from "./claude-status-detectors";
13
- import { launcherScriptPathForSession } from "../session-scratch";
14
+ import { launcherGatePathForSession, launcherScriptPathForSession } from "../session-scratch";
14
15
 
15
16
  export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
16
17
  export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
17
18
  export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
19
+ const TMUX_SOCKET_HASH_LENGTH = 20;
20
+ const TMUX_SOCKET_PATH_SAFE_BYTES = 100;
18
21
 
19
22
  export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
20
23
  if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
@@ -22,11 +25,46 @@ export function tmuxSessionName(prefix: string, instanceId: string, label?: stri
22
25
  }
23
26
 
24
27
  export function tmuxSocketName(sessionName: string): string {
25
- const socketName = sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
28
+ // `tmux -L` puts this value at <TMUX_TMPDIR>/tmux-<uid>/<socket>. Unix
29
+ // socket paths are limited to roughly 104-108 bytes, so a display-oriented
30
+ // session name must never flow into the path unbounded. Hash the complete
31
+ // session identity (including the orchestrator's agent/spawn suffix) into a
32
+ // fixed-size token; the readable session name remains unchanged in tmux/UI.
33
+ const token = createHash("sha256").update(sessionName).digest("hex").slice(0, TMUX_SOCKET_HASH_LENGTH);
34
+ const socketName = `agent-relay-${token}`;
26
35
  assertTmuxSocketPathFits(socketName);
27
36
  return socketName;
28
37
  }
29
38
 
39
+ // Pre-#1353 socket selector, used only for one-way cleanup when a runner first
40
+ // starts after upgrading. Do not assert its path length: an overlong legacy
41
+ // name is exactly what the new fixed-size selector is designed to escape.
42
+ export function legacyTmuxSocketName(sessionName: string): string {
43
+ return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
44
+ }
45
+
46
+ export function createOrReuseTmuxSession(sessionName: string, socketName: string, tmuxArgs: string[]): void {
47
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
48
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
49
+ });
50
+ // Upgrade cleanup: a pre-#1353 runner used the readable session name as its
51
+ // selector. Best-effort removal prevents a legacy pane surviving beside its
52
+ // replacement; an overlong legacy selector can fail here harmlessly.
53
+ Bun.spawnSync(tmuxCommand(legacyTmuxSocketName(sessionName), "kill-session", "-t", sessionName), {
54
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
55
+ });
56
+ const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
57
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
58
+ });
59
+ const stderr = result.stderr.toString().trim();
60
+ // A restart can race a still-live predecessor. Reuse the uniquely named,
61
+ // confirmed session rather than failing the healthy agent on "duplicate".
62
+ const reusable = /duplicate session/i.test(stderr) && tmuxHasSession(sessionName, socketName);
63
+ if (result.exitCode !== 0 && !reusable) {
64
+ throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
65
+ }
66
+ }
67
+
30
68
  export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
31
69
  return join(socketDir, socketName);
32
70
  }
@@ -34,8 +72,22 @@ export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocket
34
72
  export function assertTmuxSocketPathFits(socketName: string, socketDir = defaultTmuxSocketDir()): void {
35
73
  const path = tmuxSocketPath(socketName, socketDir);
36
74
  const bytes = Buffer.byteLength(path);
37
- if (bytes < 108) return;
38
- throw new Error(`derived name too long for tmux socket; pass a short \`label\` (socket path is ${bytes} bytes; must be < 108)`);
75
+ if (bytes <= TMUX_SOCKET_PATH_SAFE_BYTES) return;
76
+ throw new Error(`tmux socket directory is too long (socket path is ${bytes} bytes; must be <= ${TMUX_SOCKET_PATH_SAFE_BYTES}); shorten TMUX_TMPDIR`);
77
+ }
78
+
79
+ // A single failed `has-session` can be transient (and historically was at the
80
+ // Unix socket path boundary). Call after an initial miss and only declare the
81
+ // session gone if a delayed confirmation misses too.
82
+ export async function confirmTmuxSessionGone(
83
+ sessionName: string,
84
+ socketName?: string,
85
+ delayMs = 250,
86
+ hasSession: (session: string, socket?: string) => boolean = tmuxHasSession,
87
+ ): Promise<boolean> {
88
+ if (hasSession(sessionName, socketName)) return false;
89
+ await Bun.sleep(delayMs);
90
+ return !hasSession(sessionName, socketName);
39
91
  }
40
92
 
41
93
  function defaultTmuxSocketDir(): string {
@@ -104,23 +156,69 @@ export async function submitTextToTmux(sessionName: string, text: string, socket
104
156
  // tmux new-session has a hard command-length limit that includes every argv item,
105
157
  // including `-e KEY=value`. The launcher script keeps tmux argv bounded by carrying
106
158
  // both env payloads (profile JSON, tokens, etc.) and the provider command/prompt.
107
- export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
159
+ //
160
+ // `gateFifo`, when given, is read from (blocking) before the real command execs
161
+ // (#1317): the caller creates the FIFO before the tmux session exists, attaches
162
+ // `pipe-pane` once the pane is up, then writes to the FIFO to release the gate.
163
+ // That guarantees no provider output can be produced before pipe-pane is capturing
164
+ // it — a process that dies in milliseconds previously produced a 0-byte log because
165
+ // pipe-pane was attached in a separate call *after* the session (and its command)
166
+ // had already started. If the FIFO doesn't exist (e.g. mkfifo unavailable), the
167
+ // redirection fails, bash prints a warning and continues straight to `exec` — same
168
+ // racy-but-working behavior as before this fix, not a hard failure.
169
+ export function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string, gateFifo?: string): string {
108
170
  const scriptPath = launcherScriptPathForSession(sessionName);
109
171
  const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
110
172
  const exports = Object.entries(env)
111
173
  .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
112
174
  .join("\n");
113
- writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
175
+ const gate = gateFifo ? `read -r _ < ${shellQuote(gateFifo)} || true\n` : "";
176
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}${gate}exec ${shellCmd}\n`, { mode: 0o700 });
114
177
  return scriptPath;
115
178
  }
116
179
 
180
+ // Create the gate FIFO before the tmux session starts. Best-effort: if `mkfifo`
181
+ // is unavailable, the launcher script's `read < fifo` simply fails-and-continues
182
+ // (see writeLauncherScript), so callers don't need to branch on this return value.
183
+ export function createLauncherGate(gateFifo: string): void {
184
+ try { rmSync(gateFifo, { force: true }); } catch {}
185
+ Bun.spawnSync(["mkfifo", "-m", "600", gateFifo], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
186
+ }
187
+
188
+ // Release a launcher gate created by `createLauncherGate`, unblocking the
189
+ // launcher script's `read`. Opening a FIFO for writing blocks until a reader is
190
+ // present, so this runs the write in a short-lived subprocess with a timeout
191
+ // guard: if nothing ever reads the FIFO (session/launcher failed to start), we
192
+ // give up rather than hang the caller forever.
193
+ export async function releaseLauncherGate(gateFifo: string, timeoutMs = 5_000): Promise<void> {
194
+ try {
195
+ const proc = Bun.spawn(["sh", "-c", `printf x > ${shellQuote(gateFifo)}`], {
196
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
197
+ });
198
+ const timedOut = await Promise.race([
199
+ proc.exited.then(() => false),
200
+ Bun.sleep(timeoutMs).then(() => true),
201
+ ]);
202
+ if (timedOut) {
203
+ try { proc.kill(); } catch {}
204
+ }
205
+ } catch {
206
+ // best-effort — a stuck launcher gate is no worse than the pre-#1317 race.
207
+ } finally {
208
+ try { rmSync(gateFifo, { force: true }); } catch {}
209
+ }
210
+ }
211
+
117
212
  function shellEnvKey(key: string): string {
118
213
  if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
119
214
  throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
120
215
  }
121
216
 
122
217
  export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
123
- const keys = new Set<string>();
218
+ // #1222 PATH is always carried through, not just AGENT_RELAY_/CLAUDE_ vars, so the
219
+ // launcher script's exported PATH (runtime bin dir prepended by buildSpawnArgs) reaches
220
+ // the tmux pane instead of whatever ambient PATH the tmux server happened to inherit.
221
+ const keys = new Set<string>(["PATH"]);
124
222
  for (const key of Object.keys(env)) {
125
223
  if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
126
224
  keys.add(key);
@@ -1,9 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join, resolve } from "node:path";
3
+ import { delimiter, join, resolve } from "node:path";
4
4
  import { type Message } from "agent-relay-sdk";
5
5
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
6
6
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
7
+ import { runtimePath } from "agent-relay-sdk/runtime-prefix";
7
8
  import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
8
9
  import { computeLivenessSignal, type LivenessInputs } from "../liveness";
9
10
  import type { LivenessSignal } from "agent-relay-sdk";
@@ -14,8 +15,9 @@ import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type Cl
14
15
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "./claude-prompt-gates";
15
16
  import { claudeActivityFromProbeAndPane, claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
16
17
  import { ClaudeSessionCapture, collectClaudeTranscriptArchiveSegment, collectClaudeTranscriptSessionEvents } from "./claude-session-capture";
17
- import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
18
+ import { CLAUDE_TMUX_READY_TIMEOUT_MS, captureTmuxPane, confirmTmuxSessionGone, createLauncherGate, createOrReuseTmuxSession, releaseLauncherGate, submitTextToTmux, tmuxEnvKeys, tmuxSessionName, tmuxSocketName, waitForClaudeInputReady, writeLauncherScript } from "./claude-tmux";
18
19
  import { claudePermissionPromptHandler } from "./claude-permission";
20
+ import { launcherGatePathForSession } from "../session-scratch";
19
21
 
20
22
  export class ClaudeAdapter implements ProviderAdapter {
21
23
  readonly provider = "claude";
@@ -314,13 +316,18 @@ export class ClaudeAdapter implements ProviderAdapter {
314
316
  ...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
315
317
  ? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
316
318
  : {}),
319
+ // #1222 — spawned sessions otherwise inherit whatever PATH the host login shell
320
+ // happens to provide, which may not include the agent-relay CLI. Prepend the
321
+ // runtime bin dir (layout-agnostic for npm and git-checkout installs, #1305) so
322
+ // the worker can always self-land/check version, without dropping the host PATH.
323
+ PATH: [runtimePath(), config.env?.PATH ?? process.env.PATH ?? ""].filter(Boolean).join(delimiter),
317
324
  AGENT_RELAY_PROVIDER: "claude",
318
325
  ...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
319
326
  },
320
327
  };
321
328
  }
322
329
 
323
- buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string } {
330
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string; gateFifo?: string } {
324
331
  const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
325
332
  const socketName = tmuxSocketName(sessionName);
326
333
  const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
@@ -330,33 +337,32 @@ export class ClaudeAdapter implements ProviderAdapter {
330
337
  const env: Record<string, string> = {};
331
338
  for (const key of envKeys) if (spawnArgs.env[key] !== undefined) env[key] = spawnArgs.env[key]!;
332
339
 
340
+ // Only gate the launch behind pipe-pane attachment when there's a log file to
341
+ // protect (#1317) — no point serializing dev/interactive spawns that never
342
+ // read AGENT_RELAY_LOG_FILE.
343
+ const gateFifo = spawnArgs.env.AGENT_RELAY_LOG_FILE ? launcherGatePathForSession(sessionName) : undefined;
344
+
333
345
  // tmux new-session has a hard command-length limit that includes every argv item,
334
346
  // including `-e KEY=value`. Keep tmux argv bounded by putting both env payloads
335
347
  // (profile JSON, tokens, etc.) and the provider command/prompt in a session script.
336
- const launcherScript = writeLauncherScript(sessionName, env, shellCmd);
348
+ const launcherScript = writeLauncherScript(sessionName, env, shellCmd, gateFifo);
337
349
  tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
338
- return { sessionName, socketName, args: tmuxArgs, launcherScript };
350
+ return { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo };
339
351
  }
340
352
 
341
353
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
342
- const { sessionName, socketName, args: tmuxArgs, launcherScript } = this.buildTmuxArgs(config, spawnArgs);
354
+ const { sessionName, socketName, args: tmuxArgs, launcherScript, gateFifo } = this.buildTmuxArgs(config, spawnArgs);
343
355
  this.modelUnavailableReported = false;
344
356
  this.connectionRetryActive = false;
345
357
  this.promptGatePaneState = initialClaudePromptGatePaneState();
346
358
 
347
- Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
348
- stdin: "ignore", stdout: "ignore", stderr: "ignore",
349
- });
350
-
351
- const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
352
- stdin: "ignore",
353
- stdout: "pipe",
354
- stderr: "pipe",
355
- });
359
+ // Create the gate FIFO *before* the session exists the launcher script blocks
360
+ // on reading it, so no output can be produced until we release it below (#1317).
361
+ if (gateFifo) createLauncherGate(gateFifo);
356
362
 
357
- if (result.exitCode !== 0) {
358
- const stderr = result.stderr.toString().trim();
359
- throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
363
+ try { createOrReuseTmuxSession(sessionName, socketName, tmuxArgs); } catch (error) {
364
+ if (gateFifo) await releaseLauncherGate(gateFifo);
365
+ throw error;
360
366
  }
361
367
 
362
368
  this.watchTmuxSession(sessionName, socketName);
@@ -369,6 +375,10 @@ export class ClaudeAdapter implements ProviderAdapter {
369
375
  });
370
376
  }
371
377
 
378
+ // Release the gate now that pipe-pane is attached (or there's nothing to
379
+ // protect) — the pane's launcher script was blocked on this since it started.
380
+ if (gateFifo) await releaseLauncherGate(gateFifo);
381
+
372
382
  return {
373
383
  pid: undefined, process: undefined, meta: {
374
384
  env: spawnArgs.env,
@@ -385,11 +395,21 @@ export class ClaudeAdapter implements ProviderAdapter {
385
395
 
386
396
  private watchTmuxSession(sessionName: string, socketName?: string): void {
387
397
  if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
388
- this.tmuxWatcher = setInterval(() => {
398
+ let goneConfirmationPending = false;
399
+ const watcher = setInterval(() => {
389
400
  if (!tmuxHasSession(sessionName, socketName)) {
390
- clearInterval(this.tmuxWatcher!);
391
- this.tmuxWatcher = undefined;
392
- this.statusCb("offline");
401
+ if (goneConfirmationPending) return;
402
+ goneConfirmationPending = true;
403
+ void confirmTmuxSessionGone(sessionName, socketName).then((gone) => {
404
+ // A respawn can replace the watcher while this delayed confirmation
405
+ // is in flight. Never let the predecessor callback stop its successor.
406
+ if (this.tmuxWatcher !== watcher) return;
407
+ goneConfirmationPending = false;
408
+ if (!gone) return;
409
+ clearInterval(watcher);
410
+ this.tmuxWatcher = undefined;
411
+ this.statusCb("offline");
412
+ });
393
413
  return;
394
414
  }
395
415
  if (this.modelUnavailableReported) return;
@@ -431,6 +451,7 @@ export class ClaudeAdapter implements ProviderAdapter {
431
451
  if (promptGate.status) this.statusCb(promptGate.status);
432
452
  }
433
453
  }, 2000);
454
+ this.tmuxWatcher = watcher;
434
455
  }
435
456
 
436
457
  // Best-effort: a failed auto-dismiss must not wedge the pane watcher.
@@ -448,7 +469,6 @@ export class ClaudeAdapter implements ProviderAdapter {
448
469
  clearInterval(this.tmuxWatcher);
449
470
  this.tmuxWatcher = undefined;
450
471
  }
451
-
452
472
  if (opts.graceful && tmuxHasSession(sessionName, socketName)) {
453
473
  await submitTextToTmux(sessionName, CLAUDE_EXIT_COMMAND, socketName);
454
474
 
@@ -81,6 +81,18 @@ interface AccountRateLimitsResponse {
81
81
 
82
82
  export const CODEX_APP_CLIENT_EVENT_CAP = 5_000;
83
83
 
84
+ // #1339: every JSON-RPC method the app-server exposes acknowledges promptly — turn/start
85
+ // returns the created turn immediately (turn progress/completion arrive later as separate
86
+ // notifications), compact/start acks and reports completion via thread/compacted, etc. So a
87
+ // request whose response never arrives (a half-open socket, a lost frame, an app-server that
88
+ // wedged mid-handling) is always a fault, never normal latency. Without a ceiling, request()
89
+ // would hang forever; because deliver() awaits turnStart under the runner's single-flight
90
+ // `delivering` latch, one hung request permanently wedges the whole input pump — the agent
91
+ // looks "unresponsive": input is accepted but no turn ever starts and nothing new drains.
92
+ // Reject after this ceiling so the hang surfaces (logged delivery failure) and the pump
93
+ // recovers by re-queuing. Generous vs. the sub-second normal ack, so no false timeouts.
94
+ export const CODEX_APP_CLIENT_REQUEST_TIMEOUT_MS = 30_000;
95
+
84
96
  export class CodexAppClient {
85
97
  private ws!: WebSocket;
86
98
  private nextId = 1;
@@ -90,7 +102,11 @@ export class CodexAppClient {
90
102
  private connected = false;
91
103
  private connectionListeners = new Set<(connected: boolean) => void>();
92
104
 
93
- constructor(private readonly url: string, private readonly log: (msg: string) => void = () => {}) {}
105
+ constructor(
106
+ private readonly url: string,
107
+ private readonly log: (msg: string) => void = () => {},
108
+ private readonly requestTimeoutMs: number = CODEX_APP_CLIENT_REQUEST_TIMEOUT_MS,
109
+ ) {}
94
110
 
95
111
  async connect(): Promise<void> {
96
112
  if (this.connected) return;
@@ -223,7 +239,22 @@ export class CodexAppClient {
223
239
  const id = this.nextId++;
224
240
  const payload: JsonRpcRequest = { id, method, params };
225
241
  const promise = new Promise<T>((resolve, reject) => {
226
- this.pending.set(id, { resolve, reject });
242
+ // #1339: cap the wait so a response that never arrives can't hang forever. The timer
243
+ // is armed here and cleared by settle() the moment the matching frame lands (or the
244
+ // socket closes and onclose rejects every pending). unref so a pending request never
245
+ // by itself keeps the process alive.
246
+ const timer = setTimeout(() => {
247
+ if (!this.pending.has(id)) return;
248
+ this.pending.delete(id);
249
+ this.log(`request timed out after ${this.requestTimeoutMs}ms: ${method} (id=${id})`);
250
+ reject(new Error(`codex request timed out after ${this.requestTimeoutMs}ms: ${method}`));
251
+ }, this.requestTimeoutMs);
252
+ (timer as { unref?: () => void }).unref?.();
253
+ const settle = (fn: (value: any) => void) => (value: unknown) => {
254
+ clearTimeout(timer);
255
+ fn(value);
256
+ };
257
+ this.pending.set(id, { resolve: settle(resolve), reject: settle(reject) });
227
258
  });
228
259
  this.ws.send(JSON.stringify(payload));
229
260
  return promise;
@@ -3,6 +3,7 @@ import { isRecord, stringValue } from "agent-relay-sdk";
3
3
  import { isPidAlive, killPid, processTreePids, processTreePidsFromTable, waitForPidsExit } from "agent-relay-sdk/process-utils";
4
4
  import { profileAllowsRelayFeature, providerMessageText, relayContext, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderPermissionDecisionInput, type ProviderSessionEvent, type ProviderStatusUpdate, type RunnerSpawnConfig, type SpawnArgs, type TerminalAttachSpec } from "../adapter";
5
5
  import { workspaceDepsNoteFromEnv } from "../relay-instructions";
6
+ import { CODEX_TURN_IN_PROGRESS_MESSAGE } from "../runner-helpers";
6
7
  import { tomlString } from "../relay-mcp";
7
8
  import { assembleLaunch, bundledCodexSkillDirs, bundledSkillConfigArgs, materializeLaunchAssembly } from "../launch-assembly";
8
9
  import { logger } from "../logger";
@@ -62,6 +63,9 @@ export class CodexAdapter implements ProviderAdapter {
62
63
  // Active turn id for the main thread, captured from turn/started so an interrupt
63
64
  // can target the in-flight turn. Cleared on turn/completed.
64
65
  private activeTurnId?: string;
66
+ // #1339: the turn id we last logged a delivery-deferral for, so the once-per-turn "held
67
+ // behind active turn" notice isn't repeated on every ~1s runner retry during that turn.
68
+ private deferredForTurnId?: string;
65
69
  // #653: monotonic id for a turn we synthesize from live item activity when a real
66
70
  // turn/started never reached us — so a working Codex agent isn't reported `idle`.
67
71
  private syntheticTurnSeq = 0;
@@ -331,13 +335,16 @@ export class CodexAdapter implements ProviderAdapter {
331
335
  };
332
336
  }
333
337
 
334
- async deliverInitialPrompt(process: ManagedProcess, prompt: string, _options?: { readyTimeoutMs?: number }): Promise<void> {
335
- const text = prompt.trim(); // _options.readyTimeoutMs (#329) is a Claude-TUI concern; ignored here
338
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number; isInitial?: boolean }): Promise<void> {
339
+ const text = prompt.trim(); // options.readyTimeoutMs (#329) is a Claude-TUI concern; ignored here
336
340
  if (!text) return;
337
341
  const threadId = await ensureCodexThread(process);
342
+ // #1228: only a genuine first turn gets the initial-prompt label — a mid-session
343
+ // dashboard continue/injection (isInitial: false) is delivered as a plain turn.
344
+ const isInitial = options?.isInitial !== false;
338
345
  let input = [
339
346
  codexLaunchContext(process),
340
- "[agent-relay initial prompt from dashboard]",
347
+ ...(isInitial ? ["[agent-relay initial prompt from dashboard]"] : []),
341
348
  text,
342
349
  ].filter(Boolean).join("\n\n");
343
350
  if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
@@ -351,6 +358,21 @@ export class CodexAdapter implements ProviderAdapter {
351
358
 
352
359
  async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
353
360
  const threadId = await ensureCodexThread(process);
361
+ // #1339: Codex has no queue-while-busy. Claude's transports (tmux paste / monitor stdout)
362
+ // land in the CLI's own input buffer, which the CLI drains after the current turn; a Codex
363
+ // turn/start issued mid-turn has no such buffer — the app-server drops it (or wedges),
364
+ // so a relay message arriving during a turn is silently lost. Reject BEFORE composing text
365
+ // (so no relay/launch-context side effects are consumed on a message we didn't send): the
366
+ // runner re-queues and re-drains once the active turn ends, giving Claude-parity queueing.
367
+ // A trailing agent-visible turn/completed clears activeTurnId; probeActivity (polled every
368
+ // 4s) clears it too if that event is ever missed, so this can't wedge on a stale turn id.
369
+ if (this.activeTurnId) {
370
+ if (this.deferredForTurnId !== this.activeTurnId) {
371
+ this.deferredForTurnId = this.activeTurnId;
372
+ logger.info("codex", `holding ${messages.length} relay message${messages.length === 1 ? "" : "s"} behind active turn ${this.activeTurnId}; will deliver after it completes`);
373
+ }
374
+ throw new Error(CODEX_TURN_IN_PROGRESS_MESSAGE);
375
+ }
354
376
  let text = [codexLaunchContext(process), providerMessageText(messages)].filter(Boolean).join("\n\n");
355
377
  if (codexRelayContextEnabled(process) && !process.meta?.relayContextSent) {
356
378
  text = codexRelayContextBlock() + "\n\n" + text;
@@ -4,6 +4,7 @@ const BUSY_RECONCILE_POLL_MS = 4_000;
4
4
  const BUSY_RECONCILE_IDLE_CONFIRM = 8;
5
5
  const BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY = 15;
6
6
  const INTERRUPT_RECONCILE_DELAY_MS = 1_500;
7
+ const PROVIDER_TURN_CLEAR_TIMEOUT_MS = 60_000;
7
8
 
8
9
  interface BusyReconcilerDeps {
9
10
  isStopped(): boolean;
@@ -24,6 +25,7 @@ export class BusyReconciler {
24
25
  private idleStreak = 0;
25
26
  private timer?: ReturnType<typeof setInterval>;
26
27
  private sawBusy = false;
28
+ private readonly providerTurnClearWaiters = new Set<(error?: Error) => void>();
27
29
 
28
30
  constructor(private readonly deps: BusyReconcilerDeps) {}
29
31
 
@@ -55,6 +57,28 @@ export class BusyReconciler {
55
57
  }, INTERRUPT_RECONCILE_DELAY_MS);
56
58
  }
57
59
 
60
+ /** Await the same provider-turn-clear signal maintained by status events and the
61
+ * interrupt backstop. Redirect uses this boundary before starting its next turn. */
62
+ waitForProviderTurnClear(timeoutMs = PROVIDER_TURN_CLEAR_TIMEOUT_MS): Promise<void> {
63
+ if (!this.deps.hasProviderTurn()) return Promise.resolve();
64
+ return new Promise<void>((resolve, reject) => {
65
+ let timeout: ReturnType<typeof setTimeout>;
66
+ const finish = (error?: Error) => {
67
+ clearTimeout(timeout);
68
+ this.providerTurnClearWaiters.delete(finish);
69
+ if (error) reject(error); else resolve();
70
+ };
71
+ timeout = setTimeout(() => finish(new Error("timed out waiting for interrupted provider turn to clear")), timeoutMs);
72
+ timeout.unref?.();
73
+ this.providerTurnClearWaiters.add(finish);
74
+ });
75
+ }
76
+
77
+ providerTurnCleared(): void {
78
+ if (this.deps.hasProviderTurn()) return;
79
+ for (const finish of [...this.providerTurnClearWaiters]) finish();
80
+ }
81
+
58
82
  private async run(): Promise<void> {
59
83
  const probe = this.deps.probeActivity();
60
84
  if (this.deps.isStopped() || !this.deps.hasProcess() || !probe) {
package/src/config.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "agent-relay-sdk";
@@ -294,7 +294,8 @@ export function writeProviderConfig(provider: string, config: ProviderConfig, ho
294
294
  const dir = providersDir(home);
295
295
  mkdirSync(dir, { recursive: true });
296
296
  const path = join(dir, `${provider}.json`);
297
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
297
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
298
+ chmodSync(path, 0o600);
298
299
  return { ...config, path };
299
300
  }
300
301
 
@@ -1,13 +1,15 @@
1
- import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
5
- import type { ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
5
+ import { isRecord, type ProvisioningMcpServer, type SpawnProvider } from "agent-relay-sdk";
6
6
  import { getManifest } from "agent-relay-providers";
7
7
  import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
8
8
  import { claudeRelayManual } from "./relay-instructions";
9
9
  import { providerHomeRootFromEnv } from "./config";
10
10
  import { applyClaudeConfigPromptGatePreventions } from "./adapters/claude-prompt-gates";
11
+ import { writeProviderHomeMarker } from "./provider-home-marker";
12
+ import { reclaimProviderHomeTombstone } from "./provider-home-tombstone";
11
13
 
12
14
  type ProviderHome = {
13
15
  path: string;
@@ -94,7 +96,20 @@ const BOOTSTRAP_HOOKS = new Map<string, BootstrapHook>();
94
96
  function prepareProviderHome(provider: SpawnProvider, config: RunnerSpawnConfig): ProviderHome | undefined {
95
97
  if (!profileRequiresIsolatedHome(provider, config)) return undefined;
96
98
  const target = providerHomePath(provider, config);
99
+ // #1256 spawn-side reclaim — FIRST recover any quarantine tombstone the reaper left for
100
+ // this exact instanceId (atomic rename back to `target`), BEFORE creating a fresh home.
101
+ // This makes reconnection always safe (a reconnecting/resuming agent recovers its home)
102
+ // and lets a crash-stranded tombstone self-heal. A lost reclaim race just falls through
103
+ // to a fresh mkdir below — never a corrupt home.
104
+ reclaimProviderHomeTombstone(target);
97
105
  mkdirSync(target, { recursive: true });
106
+ // #1256 — stamp the owner-marker so the server's age-tier reaper can later reclaim
107
+ // this home if it becomes orphaned, but only ever on THIS host (cross-host safety).
108
+ writeProviderHomeMarker(target, {
109
+ provider,
110
+ profile: sanitizePathPart(config.agentProfile?.name || config.profile || "profile"),
111
+ instanceId: sanitizePathPart(config.instanceId || config.runnerId),
112
+ });
98
113
  const hook = BOOTSTRAP_HOOKS.get(provider);
99
114
  if (hook) hook(target, config);
100
115
  const manifest = getManifest(provider);
@@ -117,7 +132,7 @@ function bootstrapClaudeFirstRun(claudeHome: string, config: RunnerSpawnConfig):
117
132
  if (profileAllowsRelayFeature(config, "context") && !profileAllowsHostGlobalInstructions(config)) {
118
133
  writeClaudeRelayManual(claudeHome);
119
134
  }
120
- seedClaudeConfigIfMissing(claudeHome, config);
135
+ ensureClaudeConfigSeed(claudeHome, config);
121
136
  }
122
137
 
123
138
  BOOTSTRAP_HOOKS.set("codex", bootstrapCodexFirstRun);
@@ -130,12 +145,91 @@ export function providerSourceHome(provider: string): string {
130
145
  return (envVar && process.env[envVar]) || join(homedir(), configDir);
131
146
  }
132
147
 
133
- function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig): void {
148
+ function ensureClaudeConfigSeed(claudeHome: string, config: RunnerSpawnConfig): void {
134
149
  const path = join(claudeHome, ".claude.json");
135
- if (existsSync(path)) return;
136
150
  const host = readHostClaudeConfig();
137
151
  const seed = applyClaudeConfigPromptGatePreventions({}, host, config);
138
- writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
152
+ if (!existsSync(path)) {
153
+ writeJsonAtomic(path, seed);
154
+ return;
155
+ }
156
+ // #1368/#harden — the isolated home is instance-keyed and PERSISTS across resumes/reconnects, so a
157
+ // relaunch (e.g. auto-resume after a tmux/provider exit) reuses the existing `.claude.json` and
158
+ // never re-runs the absent-file seed above. Re-heal every prompt-gate key the seed would have
159
+ // applied on a fresh home (onboarding, theme, bypass-permissions, workspace-trust): a resumed
160
+ // config stale/missing on ANY of them boots straight into that gate's interactive prompt, can't
161
+ // answer headlessly, and crash-loops the agent offline. A corrupt/unparseable/non-object existing
162
+ // file can't be surgically merged — its prior contents are unrecoverable anyway — so it gets a
163
+ // full reseed instead of bailing and leaving every gate unguarded; a well-formed file gets a
164
+ // sibling-preserving merge.
165
+ const existing = readExistingClaudeConfig(path);
166
+ if (!existing) {
167
+ writeJsonAtomic(path, seed);
168
+ return;
169
+ }
170
+ if (mergeClaudeConfigGates(existing, seed)) writeJsonAtomic(path, existing);
171
+ }
172
+
173
+ // Parses an existing `.claude.json`. Returns undefined when the file is unparseable JSON or not a
174
+ // plain object, signaling the caller to reseed fully rather than merge into it.
175
+ function readExistingClaudeConfig(path: string): Record<string, unknown> | undefined {
176
+ try {
177
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
178
+ return isRecord(parsed) ? parsed : undefined;
179
+ } catch {
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ // Idempotently re-applies every key the seed computed onto an already-existing, well-formed config,
185
+ // without touching any sibling key the provider has written since first spawn (session history,
186
+ // other project entries, etc.). Boolean gate keys (hasCompletedOnboarding, bypassPermissionsModeAccepted,
187
+ // and the two workspace-trust flags) are forced to `true` whenever they aren't already `true`, so a
188
+ // stale `false` is promoted the same as a missing key. Non-boolean keys (theme, lastOnboardingVersion)
189
+ // are only filled in when entirely absent, so a value the provider or user has since set is never
190
+ // clobbered. `seed` already reflects each prevention's own gating (e.g. bypassPermissionsModeAccepted
191
+ // is simply absent unless approvalMode is "open"), so a guarded profile is never forced into it here.
192
+ // Returns whether anything changed.
193
+ function mergeClaudeConfigGates(existing: Record<string, unknown>, seed: Record<string, unknown>): boolean {
194
+ let dirty = false;
195
+ for (const [key, value] of Object.entries(seed)) {
196
+ if (key === "projects") continue;
197
+ if (typeof value === "boolean") {
198
+ if (existing[key] !== true) {
199
+ existing[key] = true;
200
+ dirty = true;
201
+ }
202
+ } else if (existing[key] === undefined) {
203
+ existing[key] = value;
204
+ dirty = true;
205
+ }
206
+ }
207
+ if (isRecord(seed.projects)) {
208
+ const existingProjects = isRecord(existing.projects) ? existing.projects : {};
209
+ for (const [projectPath, seedEntry] of Object.entries(seed.projects)) {
210
+ if (!isRecord(seedEntry)) continue;
211
+ const existingEntry = isRecord(existingProjects[projectPath]) ? existingProjects[projectPath] : {};
212
+ for (const [entryKey, entryValue] of Object.entries(seedEntry)) {
213
+ if (typeof entryValue !== "boolean") continue;
214
+ if (existingEntry[entryKey] !== true) {
215
+ existingEntry[entryKey] = true;
216
+ dirty = true;
217
+ }
218
+ }
219
+ existingProjects[projectPath] = existingEntry;
220
+ }
221
+ existing.projects = existingProjects;
222
+ }
223
+ return dirty;
224
+ }
225
+
226
+ // Atomic write: temp file in the SAME directory, then rename(2). A crash or kill mid-write leaves
227
+ // only an orphaned temp file — never a truncated/corrupt `.claude.json` — because rename only ever
228
+ // swaps in a complete file.
229
+ function writeJsonAtomic(path: string, value: unknown): void {
230
+ const tmpPath = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
231
+ writeFileSync(tmpPath, JSON.stringify(value, null, 2), { mode: 0o600 });
232
+ renameSync(tmpPath, path);
139
233
  }
140
234
 
141
235
  function readHostClaudeConfig(): Record<string, unknown> | undefined {
@@ -0,0 +1,126 @@
1
+ // Owner-marker for isolated provider-config homes (#1256 age-tier / #1253 cross-host
2
+ // safety). A small JSON file written into each home root when it is materialized. It
3
+ // records which relay instance OWNS the home so the server's periodic reaper can safely
4
+ // age-reap a genuinely-orphaned home (no registered agent) WITHOUT ever touching a home
5
+ // that belongs to a different relay instance / remote host that happens to share the
6
+ // same on-disk layout.
7
+ //
8
+ // KEY CORRECTNESS INVARIANT: the marker is WRITTEN here by the runner/child-spawn path
9
+ // but READ by the server-side reaper (src/provider-home-gc.ts). Both must derive THIS
10
+ // instance's owner identity the same way, or the reaper would either never match its own
11
+ // homes (no reclaim) or match a foreign one (unsafe delete). The single source of truth
12
+ // is resolveLocalOrchestratorId() (src/upgrade-local-id.ts) — the canonical
13
+ // orchestrator-id anchor honoring the orchestrator.json `id`, the
14
+ // AGENT_RELAY_ORCHESTRATOR_ID env override, and finally the hostname. Bare os.hostname()
15
+ // is NOT collision-resistant (#1256 finding 2): two hosts/containers sharing a hostname
16
+ // but running separate Relay instances would each see the other's home as "own-host, no
17
+ // agent" and delete a LIVE home after the TTL. The runner child and the relay server run
18
+ // with the same HOME/env on the same instance, so resolveLocalOrchestratorId() agrees
19
+ // across them; a different relay instance resolves a different id, which is exactly the
20
+ // cross-instance reap guard. `host` (bare hostname) is retained as an informational field
21
+ // only — never the authoritative own-owner match.
22
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
23
+ import { hostname } from "node:os";
24
+ import { join } from "node:path";
25
+ import { resolveLocalOrchestratorId } from "agent-relay-sdk";
26
+
27
+ export const PROVIDER_HOME_MARKER_FILENAME = ".agent-relay-home.json";
28
+ export const PROVIDER_HOME_MARKER_VERSION = 1;
29
+
30
+ export interface ProviderHomeMarker {
31
+ /** Marker schema version. Only the exact known version (1) is ever trusted. */
32
+ v: number;
33
+ /**
34
+ * Authoritative owning-instance identity — resolveLocalOrchestratorId(). The one
35
+ * anchor shared by the marker writer (runner) and reader (server reaper); the reaper
36
+ * age-reaps ONLY homes whose `owner` equals this instance's own id.
37
+ */
38
+ owner: string;
39
+ /** Informational only: bare os.hostname() at write time. NOT the authoritative match. */
40
+ host: string;
41
+ provider: string;
42
+ profile: string;
43
+ instanceId: string;
44
+ /** Epoch ms the home was first materialized (stable across re-materialization). */
45
+ createdAt: number;
46
+ }
47
+
48
+ /**
49
+ * THIS instance's authoritative owner identity. The one anchor shared by the marker
50
+ * writer and reader — collision-resistant across hosts that share a hostname (#1256
51
+ * finding 2). Must resolve identically on the runner (write) and server (read) sides.
52
+ */
53
+ export function providerHomeOwnerId(): string {
54
+ return resolveLocalOrchestratorId();
55
+ }
56
+
57
+ export function providerHomeMarkerPath(homePath: string): string {
58
+ return join(homePath, PROVIDER_HOME_MARKER_FILENAME);
59
+ }
60
+
61
+ function isNonEmptyString(value: unknown): value is string {
62
+ return typeof value === "string" && value.length > 0;
63
+ }
64
+
65
+ /**
66
+ * Read + strictly validate a home's owner-marker. Returns null on
67
+ * missing/unreadable/unparseable/malformed — the reaper treats null as "unmarked →
68
+ * never age-reap" (conservative). Validation (#1256 finding 3) rejects unless:
69
+ * - `v` is EXACTLY the known schema version (1) — not merely "a number", so a marker
70
+ * from an unknown/future/0 schema is never trusted.
71
+ * - every required identity field is a NON-EMPTY string (empty strings are rejected;
72
+ * a partially-written/blank marker must never be treated as authoritative).
73
+ * - `createdAt` is a finite number.
74
+ * The reaper additionally confirms the marker actually describes THIS directory
75
+ * (provider/profile/instanceId path-segment match) and that `owner` is this instance —
76
+ * see src/provider-home-gc.ts. Any failure → skip, never reap.
77
+ */
78
+ export function readProviderHomeMarker(homePath: string): ProviderHomeMarker | null {
79
+ try {
80
+ const parsed = JSON.parse(readFileSync(providerHomeMarkerPath(homePath), "utf8")) as Partial<ProviderHomeMarker>;
81
+ if (
82
+ !parsed ||
83
+ parsed.v !== PROVIDER_HOME_MARKER_VERSION ||
84
+ !isNonEmptyString(parsed.owner) ||
85
+ !isNonEmptyString(parsed.host) ||
86
+ !isNonEmptyString(parsed.provider) ||
87
+ !isNonEmptyString(parsed.profile) ||
88
+ !isNonEmptyString(parsed.instanceId) ||
89
+ typeof parsed.createdAt !== "number" ||
90
+ !Number.isFinite(parsed.createdAt)
91
+ ) {
92
+ return null;
93
+ }
94
+ return parsed as ProviderHomeMarker;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Write the owner-marker into a materialized home root. Idempotent: safe to call on
102
+ * every (re)materialization; a pre-existing valid marker's createdAt is preserved so
103
+ * the recorded age is stable. Best-effort — a home we can't mark simply stays
104
+ * un-age-reapable, which is the safe failure mode.
105
+ */
106
+ export function writeProviderHomeMarker(
107
+ homePath: string,
108
+ fields: { provider: string; profile: string; instanceId: string },
109
+ now: number = Date.now(),
110
+ ): void {
111
+ const existing = existsSync(homePath) ? readProviderHomeMarker(homePath) : null;
112
+ const marker: ProviderHomeMarker = {
113
+ v: PROVIDER_HOME_MARKER_VERSION,
114
+ owner: providerHomeOwnerId(),
115
+ host: hostname(),
116
+ provider: fields.provider,
117
+ profile: fields.profile,
118
+ instanceId: fields.instanceId,
119
+ createdAt: existing?.createdAt ?? now,
120
+ };
121
+ try {
122
+ writeFileSync(providerHomeMarkerPath(homePath), JSON.stringify(marker), { mode: 0o600 });
123
+ } catch {
124
+ // best-effort; leave the home unmarked (safe: unmarked homes are never age-reaped)
125
+ }
126
+ }
@@ -0,0 +1,131 @@
1
+ // Quarantine/delete tombstone naming + spawn-side reclaim for provider-home GC (#1256).
2
+ //
3
+ // The reaper NEVER rename-then-immediately-rm's a home. Instead it decouples the
4
+ // destructive delete from any live registration with a quarantine window and two disjoint
5
+ // sibling name-spaces, both of which the candidate scanner excludes:
6
+ //
7
+ // `<instanceId>.reaping-<quarantineMs>-<seq>` RECLAIM-able quarantine tombstone.
8
+ // Pass A of the reaper renames an eligible orphan here (no rm). A spawning runner
9
+ // that materializes the SAME instanceId reclaims it (atomic rename back to the live
10
+ // path) BEFORE creating a fresh home — so a reconnecting/resuming agent always
11
+ // recovers its home, and a crash-stranded tombstone self-heals on the next spawn.
12
+ //
13
+ // `<instanceId>.deleting-<nowMs>-<seq>` COMMITTED delete-staging dir.
14
+ // Pass B, only for tombstones quarantined past the quarantine TTL and still with no
15
+ // live owner, atomically renames the `.reaping-*` tombstone to a `.deleting-*` name
16
+ // and THEN rm's it. The spawn-side reclaim only ever touches `.reaping-*`, never a
17
+ // `.deleting-*`, so this rename is the single filesystem linearization point of the
18
+ // delete-vs-spawn-reclaim race:
19
+ // - reclaim wins → `.reaping-*` → live path; Pass B's stage rename ENOENTs and
20
+ // aborts. The home is live and intact.
21
+ // - Pass B wins → `.reaping-*` → `.deleting-*`; the racing spawn finds no
22
+ // `.reaping-*` and builds a clean fresh home at the live path. Pass B rm's the
23
+ // `.deleting-*` (the dead agent's old copy). No half-deleted live home is ever
24
+ // possible.
25
+ //
26
+ // The two infixes are deliberately disjoint and both end-anchored + numeric so a real
27
+ // instanceId (a UUID / `<host>-<provider>-<label>-<uuid8>`) is never mistaken for one.
28
+ import { existsSync, readdirSync, renameSync } from "node:fs";
29
+ import { basename, dirname, join } from "node:path";
30
+ import { readProviderHomeMarker } from "./provider-home-marker";
31
+
32
+ export const TOMBSTONE_INFIX = ".reaping-";
33
+ export const DELETING_INFIX = ".deleting-";
34
+
35
+ // End-anchored + numeric: `<instanceId>.reaping-<quarantineMs>-<seq>`. The leading `.+`
36
+ // is greedy, so an instanceId that itself happens to contain `.reaping-<n>` still parses
37
+ // to the correct (full) leaf — the structured, end-anchored suffix is what we added.
38
+ const TOMBSTONE_NAME_RE = /^(.+)\.reaping-(\d+)-(\d+)$/;
39
+ const DELETING_NAME_RE = /^(.+)\.deleting-(\d+)-(\d+)$/;
40
+
41
+ export interface ParsedTombstone {
42
+ instanceId: string;
43
+ quarantineMs: number;
44
+ seq: number;
45
+ }
46
+
47
+ /** Reclaim-able quarantine tombstone name for `instanceId` quarantined at `quarantineMs`. */
48
+ export function tombstoneName(instanceId: string, quarantineMs: number, seq: number): string {
49
+ return `${instanceId}${TOMBSTONE_INFIX}${quarantineMs}-${seq}`;
50
+ }
51
+
52
+ /** Committed delete-staging dir name — a name the spawn-side reclaim NEVER touches. */
53
+ export function deletingName(instanceId: string, nowMs: number, seq: number): string {
54
+ return `${instanceId}${DELETING_INFIX}${nowMs}-${seq}`;
55
+ }
56
+
57
+ export function isTombstoneName(name: string): boolean {
58
+ return TOMBSTONE_NAME_RE.test(name);
59
+ }
60
+
61
+ export function isDeletingName(name: string): boolean {
62
+ return DELETING_NAME_RE.test(name);
63
+ }
64
+
65
+ /** Any reaper-owned staging name (either name-space) — excluded from candidate scanning. */
66
+ export function isReaperStagingName(name: string): boolean {
67
+ return isTombstoneName(name) || isDeletingName(name);
68
+ }
69
+
70
+ export function parseTombstone(name: string): ParsedTombstone | null {
71
+ const m = TOMBSTONE_NAME_RE.exec(name);
72
+ if (!m) return null;
73
+ const quarantineMs = Number(m[2]);
74
+ const seq = Number(m[3]);
75
+ if (!Number.isFinite(quarantineMs) || !Number.isFinite(seq)) return null;
76
+ return { instanceId: m[1] as string, quarantineMs, seq };
77
+ }
78
+
79
+ /**
80
+ * Spawn-side reclaim (the key #1256 safety property). Called at materialization time,
81
+ * BEFORE the fresh home is created at `homePath`. If a quarantine tombstone
82
+ * (`<homePath>.reaping-*`) exists for this exact instanceId, atomically rename it back to
83
+ * `homePath` so a reconnecting/resuming agent recovers its home and a crash-stranded
84
+ * tombstone self-heals. Returns `homePath` when a tombstone was reclaimed, else null (the
85
+ * caller then creates a fresh home). Best-effort and NEVER throws — a lost reclaim race
86
+ * simply yields a fresh home, never a corrupt one.
87
+ *
88
+ * ONLY ever reclaims `.reaping-*` tombstones — never a `.deleting-*` staging dir, which the
89
+ * reaper has already committed to deleting (reclaiming one could race a live rm and revive
90
+ * a half-deleted dir at the live path). That disjointness is the whole point of the two
91
+ * name-spaces.
92
+ */
93
+ export function reclaimProviderHomeTombstone(homePath: string): string | null {
94
+ try {
95
+ // A live/fresh home already sits here — never clobber it with an old tombstone.
96
+ if (existsSync(homePath)) return null;
97
+ const parent = dirname(homePath);
98
+ const leaf = basename(homePath);
99
+ let entries: string[];
100
+ try {
101
+ entries = readdirSync(parent);
102
+ } catch {
103
+ return null;
104
+ }
105
+ const matches = entries
106
+ .map((name) => ({ name, parsed: parseTombstone(name) }))
107
+ .filter((e): e is { name: string; parsed: ParsedTombstone } => e.parsed !== null && e.parsed.instanceId === leaf)
108
+ // Collision guard: an entry whose OWN marker names the full entry name as its
109
+ // instanceId is a live home that merely looks like a tombstone (a real instanceId that
110
+ // happens to end in the tombstone name-shape) — never reclaim it as `leaf`'s home. A
111
+ // genuine tombstone of `leaf` carries a marker with instanceId === `leaf`, never its
112
+ // own (longer) tombstone name. Unmarked entries fall through (a materialized home is
113
+ // always marked, so an unmarked match is a genuine attribution-tier tombstone).
114
+ .filter((e) => readProviderHomeMarker(join(parent, e.name))?.instanceId !== e.name)
115
+ .sort((a, b) => b.parsed.quarantineMs - a.parsed.quarantineMs || b.parsed.seq - a.parsed.seq);
116
+ for (const match of matches) {
117
+ try {
118
+ renameSync(join(parent, match.name), homePath);
119
+ return homePath;
120
+ } catch {
121
+ // Lost this rename (the reaper staged it to `.deleting-*`, or another spawn grabbed
122
+ // it): if the path now exists someone else reclaimed it; otherwise try the next
123
+ // (older) tombstone, and failing all, fall through to a fresh home.
124
+ if (existsSync(homePath)) return homePath;
125
+ }
126
+ }
127
+ return null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
@@ -280,7 +280,13 @@ export class RelayMcpProxy {
280
280
  })));
281
281
  }
282
282
 
283
- if (relay.body) return Response.json(relay.body);
283
+ // #1418: propagate the relay's actual status (e.g. 413 payload-too-large, 400 validation,
284
+ // 403 auth) instead of the default 200 `Response.json` gives an untouched status option —
285
+ // a hard rejection silently reads as a normal 200 response to anything downstream that
286
+ // branches on HTTP status rather than parsing the JSON-RPC `error` field, masking a large
287
+ // delivery's outright rejection as an ambiguous "success". `forwardRaw` below already gets
288
+ // this right; `tools/call` was the one path that didn't.
289
+ if (relay.body) return Response.json(relay.body, { status: relay.status || 200 });
284
290
  // Relay down and not bufferable: a real error the agent must see (and can retry).
285
291
  return Response.json(jsonRpcError(id, -32002, `relay unreachable: ${relay.transportError ?? `status ${relay.status}`}`));
286
292
  }
package/src/relay-mcp.ts CHANGED
@@ -85,14 +85,37 @@ export function tomlString(value: string): string {
85
85
  // project-cwd `.mcp.json` is never discovered (its enable modal can't wedge a headless
86
86
  // agent). Whatever the assembler decides host mode should keep is injected here explicitly;
87
87
  // strict then preserves exactly that set and nothing else.
88
+ import { existsSync } from "node:fs";
89
+ import { dirname, join, resolve as resolvePath } from "node:path";
90
+ import { fileURLToPath } from "node:url";
88
91
  import { SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS, type ProvisioningMcpServer } from "agent-relay-sdk";
89
92
 
93
+ const __dirname = dirname(fileURLToPath(import.meta.url));
94
+
95
+ // #1301 — a bare `command: "callmux"` resolves off PATH, which can drift from the version
96
+ // this package pins (root/runner/orchestrator all pin the same `callmux` dependency). npm
97
+ // (and bun/pnpm workspaces) always link a dependency's declared bin to `node_modules/.bin/<name>`
98
+ // at whichever ancestor directory it resolves/hoists to — the same layout-agnostic-bin
99
+ // invariant #1305/#1222 rely on for the agent-relay CLI itself — so walk up from this
100
+ // module's own location to find the PINNED bridge binary instead of trusting PATH. Falls
101
+ // back to the bare name (old behavior) only if no bundled bin is found anywhere above.
102
+ export function resolveCallmuxBin(startDir: string = __dirname): string {
103
+ let dir = resolvePath(startDir);
104
+ while (true) {
105
+ const candidate = join(dir, "node_modules", ".bin", "callmux");
106
+ if (existsSync(candidate)) return candidate;
107
+ const parent = dirname(dir);
108
+ if (parent === dir) return "callmux";
109
+ dir = parent;
110
+ }
111
+ }
112
+
90
113
  // The shared-listener bridge as a provider-neutral stdio server descriptor: a `callmux bridge`
91
114
  // child fronting the host's shared listener, with per-agent `--cwd "$WORKTREE"` for session-cwd
92
115
  // isolation (#672). It flows through the existing claude/codex stdio injection unchanged.
93
116
  export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcpServer {
94
117
  return {
95
- command: "callmux",
118
+ command: resolveCallmuxBin(),
96
119
  args: ["bridge", "--url", url, "--cwd", cwd, "--call-timeout", String(SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS)],
97
120
  };
98
121
  }
@@ -689,7 +689,7 @@ export class AgentRunner {
689
689
  this.deliveringInitialPrompt = true;
690
690
  const attempt = (this.initialPromptAttempts += 1);
691
691
  try {
692
- this.recordInjectedPrompt(prompt); await this.options.adapter.deliverInitialPrompt(this.process, prompt, { readyTimeoutMs });
692
+ this.recordInjectedPrompt(prompt); await this.options.adapter.deliverInitialPrompt(this.process, prompt, { readyTimeoutMs, isInitial: true });
693
693
  this.pendingInitialPrompt = undefined;
694
694
  } catch (error) {
695
695
  const giveUp = attempt >= MAX_INITIAL_PROMPT_ATTEMPTS;
@@ -816,9 +816,17 @@ export class AgentRunner {
816
816
  providerResult = await this.options.adapter.clearContext(this.process);
817
817
  } else if (type === "agent.interrupt") {
818
818
  if (!this.options.adapter.interrupt || !this.process) throw new Error("provider does not support interrupt");
819
- this.sessionLog("interrupt requested from dashboard");
820
- providerResult = await this.options.adapter.interrupt(this.process);
821
- this.busyReconciler.scheduleInterruptReconcile();
819
+ const redirect = params.redirect && typeof params.redirect === "object" && !Array.isArray(params.redirect)
820
+ ? params.redirect as Record<string, unknown>
821
+ : undefined;
822
+ const hasProviderTurn = this.claims.activeWork().some((work) => work.kind === "provider-turn");
823
+ if (hasProviderTurn || !redirect) {
824
+ this.sessionLog(`interrupt requested via ${typeof params.requestedVia === "string" ? params.requestedVia : "dashboard"}`);
825
+ providerResult = await this.options.adapter.interrupt(this.process);
826
+ this.busyReconciler.scheduleInterruptReconcile();
827
+ }
828
+ if (redirect && hasProviderTurn) await this.busyReconciler.waitForProviderTurnClear();
829
+ if (redirect) providerResult = { interrupt: providerResult, redirect: await this.injectPrompt(redirect) };
822
830
  } else if (type === "agent.injectContext") {
823
831
  if (!this.process) throw new Error("provider process is unavailable");
824
832
  providerResult = await this.injectContext(params);
@@ -934,7 +942,9 @@ export class AgentRunner {
934
942
  // Mark so the matching UserPromptSubmit echo isn't double-posted: a chat-box
935
943
  // prompt already created its own session message shown in the dashboard.
936
944
  this.recordInjectedPrompt(prompt.trim());
937
- await this.options.adapter.deliverInitialPrompt(this.process, prompt);
945
+ // #1228: prompt injection is a manual dashboard "continue"/chat-box send, not the
946
+ // agent's genuine first turn — never label it as the initial prompt.
947
+ await this.options.adapter.deliverInitialPrompt(this.process, prompt, { isInitial: false });
938
948
  return { injected: true, messageId, attachmentCount: attachments.length };
939
949
  }
940
950
 
@@ -956,7 +966,8 @@ export class AgentRunner {
956
966
  return;
957
967
  }
958
968
  this.recordInjectedPrompt(text.trim());
959
- await this.options.adapter.deliverInitialPrompt(this.process, text).catch((error) => {
969
+ // #1228: this resolves a held AskUserQuestion mid-session — never the initial prompt.
970
+ await this.options.adapter.deliverInitialPrompt(this.process, text, { isInitial: false }).catch((error) => {
960
971
  this.sessionLog(`failed to inject AskUserQuestion answer for ${input.approvalId}: ${errMessage(error)}`);
961
972
  });
962
973
  }
@@ -1049,6 +1060,9 @@ export class AgentRunner {
1049
1060
 
1050
1061
  const nativeSelfResume = this.nativeSelfResume.active;
1051
1062
  if (nativeSelfResume && getManifest(this.options.provider)?.resume?.supported) {
1063
+ // suppressPrompt only applies (and only matters) on the no-resumeId branch below — see the
1064
+ // gate-hardening note at the tmux-exit-auto-resume call site further down for why it's a
1065
+ // no-op whenever resumeId is set, and what actually keeps a resume off the bypass gate.
1052
1066
  await recoverNativeSelfResumeExit({ handoff: nativeSelfResume, tracker: this.nativeSelfResume, providerSessionId: this.providerSessionId, status, diagnostics, now, publishTimeline: (event) => this.publishRunnerTimelineEvent(event), setTerminalProviderExit: (marker) => { this.terminalProviderExit = marker; }, setProviderStatus: (update) => this.setProviderStatus(update), restartProvider: (resumeId) => this.restartProvider(resumeId ? { resumeId } : { suppressPrompt: true }), publishStatus: () => this.publishStatus(), scheduleDrain: () => this.scheduleDrain() });
1053
1067
  return;
1054
1068
  }
@@ -1178,6 +1192,13 @@ export class AgentRunner {
1178
1192
  await Bun.sleep(delayMs);
1179
1193
  if (this.stopped || this.exitCommandInProgress) return;
1180
1194
  try {
1195
+ // #1368/#harden — a resume relaunch always carries a resumeId here, so passing
1196
+ // suppressPrompt would be a no-op (it's only consumed under !resumeId, see spawnProvider).
1197
+ // What actually keeps a skip-permissions resume off the interactive "Bypass Permissions
1198
+ // mode" gate: the config-seed re-heal in profile-home.ts, which idempotently re-applies
1199
+ // every prompt-gate key on the persisted, instance-keyed provider config home before each
1200
+ // launch, plus the adapter's always-on pane-gate send-keys watcher as a fallback if a gate
1201
+ // still renders.
1181
1202
  await this.restartProvider(restartResumeId ? { resumeId: restartResumeId } : undefined);
1182
1203
  this.publishStatus();
1183
1204
  this.scheduleDrain();
@@ -1444,6 +1465,7 @@ export class AgentRunner {
1444
1465
  if (typeof update !== "string") {
1445
1466
  for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
1446
1467
  }
1468
+ if (!this.claims.activeWork().some((work) => work.kind === "provider-turn")) this.busyReconciler.providerTurnCleared();
1447
1469
  // #329: the provider just went idle (ready). If a first initial-prompt delivery timed out on
1448
1470
  // a cold-start TUI, re-attempt now — the ready wait should resolve almost immediately.
1449
1471
  if (status === "idle" && this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
@@ -1849,6 +1871,7 @@ export class AgentRunner {
1849
1871
  if (!this.claims.activeWork().some((w) => w.kind === "provider-turn")) return;
1850
1872
  this.sessionLog(`force-clearing stuck provider-turn (${reason})`);
1851
1873
  this.claims.clearWorkKind("provider-turn");
1874
+ this.busyReconciler.providerTurnCleared();
1852
1875
  this.currentTurnId = undefined;
1853
1876
  this.compactionMidTurn = false;
1854
1877
  this.publishStatus();
@@ -1886,6 +1909,7 @@ export class AgentRunner {
1886
1909
  this.compactionMidTurn = false;
1887
1910
  this.busyReconciler.disarm();
1888
1911
  this.stopReasoningTail();
1912
+ this.busyReconciler.providerTurnCleared();
1889
1913
  }
1890
1914
  this.publishStatus();
1891
1915
  }
@@ -287,9 +287,16 @@ export function commandTimeoutMs(params: Record<string, unknown>, fallback = 10_
287
287
  return Math.min(raw, 60_000);
288
288
  }
289
289
 
290
+ // #1339: the Codex adapter rejects a delivery attempted while a turn is already in progress
291
+ // (Codex has no queue-while-busy the way Claude's input buffer does), so the runner re-queues
292
+ // and re-drains once the turn ends. That rejection is expected flow control, not a fault — the
293
+ // drain loop retries roughly once a second during a long turn, so it must not spam the log.
294
+ export const CODEX_TURN_IN_PROGRESS_MESSAGE = "codex turn in progress; deferring delivery until it completes";
295
+
290
296
  export function shouldLogDeliveryFailure(error: unknown): boolean {
291
297
  const message = errMessage(error);
292
- return message !== providerMonitorUnavailableMessage("claude");
298
+ return message !== providerMonitorUnavailableMessage("claude")
299
+ && message !== CODEX_TURN_IN_PROGRESS_MESSAGE;
293
300
  }
294
301
 
295
302
  export function runtimeTokenRenewDelayMs(expiresAtSeconds: number, nowMs = Date.now()): number | undefined {
@@ -129,6 +129,13 @@ export function launcherScriptPathForSession(sessionName: string, launcherDir?:
129
129
  return join(resolveLauncherDir(launcherDir), `${sanitized}.sh`);
130
130
  }
131
131
 
132
+ // Named-pipe path the launcher script blocks on before exec'ing the real provider
133
+ // command (#1317) — lets the caller attach `tmux pipe-pane` while the pane is still
134
+ // idle, so a process that exits within milliseconds can't slip output past it.
135
+ export function launcherGatePathForSession(sessionName: string, launcherDir?: string): string {
136
+ return `${launcherScriptPathForSession(sessionName, launcherDir)}.gate`;
137
+ }
138
+
132
139
  function dedupeBases(bases: Array<string | undefined>): string[] {
133
140
  const seen = new Set<string>();
134
141
  const out: string[] = [];
@@ -169,6 +176,7 @@ export function reapSessionLauncher(target: SessionLauncherTarget): string[] {
169
176
  for (const path of dedupePaths([
170
177
  target.launcherScript,
171
178
  target.tmuxSession ? launcherScriptPathForSession(target.tmuxSession, target.launcherDir) : undefined,
179
+ target.tmuxSession ? launcherGatePathForSession(target.tmuxSession, target.launcherDir) : undefined,
172
180
  ])) {
173
181
  try {
174
182
  const exists = statSync(path).isFile();