agent-relay-server 0.122.0 → 0.122.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -6
  3. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  4. package/runner/src/adapters/claude-delivery.ts +127 -0
  5. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  6. package/runner/src/adapters/claude-session-probe.ts +225 -0
  7. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  8. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  9. package/runner/src/adapters/claude-transcript.ts +414 -0
  10. package/runner/src/adapters/claude.ts +672 -0
  11. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  12. package/runner/src/adapters/codex-client.ts +279 -0
  13. package/runner/src/adapters/codex.ts +1345 -0
  14. package/runner/src/attachment-cache.ts +189 -0
  15. package/runner/src/busy-reconciler.ts +102 -0
  16. package/runner/src/claim-tracker.ts +140 -0
  17. package/runner/src/claude-prompt-gates.ts +268 -0
  18. package/runner/src/context-probe-state.ts +6 -0
  19. package/runner/src/continuation-archive.ts +179 -0
  20. package/runner/src/control-server.ts +639 -0
  21. package/runner/src/index.ts +396 -0
  22. package/runner/src/launch-assembly.ts +508 -0
  23. package/runner/src/liveness.ts +50 -0
  24. package/runner/src/logger.ts +99 -0
  25. package/runner/src/mcp-outbox.ts +120 -0
  26. package/runner/src/message-body-config/config.ts +3 -0
  27. package/runner/src/message-body-config.ts +1 -0
  28. package/runner/src/native-self-resume.ts +202 -0
  29. package/runner/src/outbox.ts +342 -0
  30. package/runner/src/process-meta.ts +9 -0
  31. package/runner/src/profile-home.ts +260 -0
  32. package/runner/src/profile-projection.ts +305 -0
  33. package/runner/src/providers.ts +20 -0
  34. package/runner/src/provisioning.ts +314 -0
  35. package/runner/src/quota.ts +40 -0
  36. package/runner/src/rate-limit.ts +189 -0
  37. package/runner/src/relay-injection-events.ts +102 -0
  38. package/runner/src/relay-instructions.ts +60 -0
  39. package/runner/src/relay-mcp-proxy.ts +383 -0
  40. package/runner/src/relay-mcp.ts +156 -0
  41. package/runner/src/reply-obligation-cache.ts +136 -0
  42. package/runner/src/response-capture-report.ts +63 -0
  43. package/runner/src/runner-core.ts +2409 -0
  44. package/runner/src/runner-helpers.ts +435 -0
  45. package/runner/src/runner-insights.ts +105 -0
  46. package/runner/src/runner.ts +14 -0
  47. package/runner/src/session-destroy.ts +22 -0
  48. package/runner/src/session-insights.ts +118 -0
  49. package/runner/src/session-scratch.ts +271 -0
  50. package/runner/src/template-resolver.ts +29 -0
  51. package/runner/src/version.ts +43 -0
  52. package/src/gate-resolver.ts +7 -12
@@ -0,0 +1,672 @@
1
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { type Message } from "agent-relay-sdk";
6
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
7
+ import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
8
+ import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
9
+ import { profileAllowsRelayFeature, type ManagedProcess, type ProviderAdapter, type ProviderConfig, type ProviderStatusUpdate, type RunnerSpawnConfig, type SemanticStatus, type SpawnArgs } from "../adapter";
10
+ import { computeLivenessSignal, type LivenessInputs } from "../liveness";
11
+ import type { LivenessSignal } from "agent-relay-sdk";
12
+ import { collectClaudeSessionEvents } from "./claude-transcript";
13
+ import type { SessionEvent } from "../session-insights";
14
+ import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
15
+ import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
16
+ import { claudeProviderMessageText } from "./claude-delivery";
17
+ import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
18
+ import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
19
+ import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
20
+ import { launcherScriptPathForSession } from "../session-scratch";
21
+
22
+ export class ClaudeAdapter implements ProviderAdapter {
23
+ readonly provider = "claude";
24
+ readonly compactSupportsInstructions = true;
25
+ // #352: initial prompt is seeded as Claude's positional launch arg (buildSpawnArgs) — reliable,
26
+ // no send-keys/onboarding race; tells the runner to skip the redundant post-launch delivery.
27
+ readonly seedsInitialPromptAtLaunch = true;
28
+ private statusCb: (status: ProviderStatusUpdate) => void = () => {};
29
+ private tmuxWatcher?: Timer;
30
+ private turnWatcher?: Timer;
31
+ private modelUnavailableReported = false;
32
+ private connectionRetryActive = false;
33
+ private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
34
+
35
+ onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
36
+ this.statusCb = cb;
37
+ }
38
+
39
+ // §4.1 contract verdict (#746). Claude reference (#713): Claude is token-silent during
40
+ // native compaction, but the runner publishes a `compacting` timeline transition while it
41
+ // runs — so `timelineEvent.timestamp` (folded into lastProgressAt) keeps a compacting Opus
42
+ // turn reading as progressing instead of wedged. Transport frames/acks + the server's
43
+ // lastSeen floor cover long token-silent thinking turns. The fold itself is shared; this
44
+ // override exists to document and own Claude's progress sources behind the contract.
45
+ liveness(inputs: LivenessInputs): LivenessSignal {
46
+ return computeLivenessSignal(inputs);
47
+ }
48
+
49
+ async spawn(config: RunnerSpawnConfig): Promise<ManagedProcess> {
50
+ const args = this.buildSpawnArgs(config, config.providerConfig as ProviderConfig);
51
+
52
+ if (config.headless) {
53
+ return this.spawnHeadless(config, args);
54
+ }
55
+
56
+ const proc = Bun.spawn([args.command, ...args.args], {
57
+ cwd: args.cwd,
58
+ env: args.env,
59
+ stdin: "inherit",
60
+ stdout: "inherit",
61
+ stderr: "inherit",
62
+ });
63
+ void proc.exited.then((code) => this.statusCb(code === 0 ? "offline" : "error"));
64
+ return { pid: proc.pid, process: proc, meta: { monitor: config.monitor, env: args.env } };
65
+ }
66
+
67
+ async shutdown(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
68
+ const tmuxSession = process.meta?.tmuxSession as string | undefined;
69
+ const tmuxSocket = process.meta?.tmuxSocket as string | undefined;
70
+ if (tmuxSession) {
71
+ await this.shutdownTmux(tmuxSession, opts, tmuxSocket);
72
+ return;
73
+ }
74
+ await terminateSingleProcess(process, opts);
75
+ }
76
+
77
+ async compact(process: ManagedProcess, opts?: { instructions?: string }): Promise<Record<string, unknown>> {
78
+ const session = process.meta?.tmuxSession as string | undefined;
79
+ const socket = process.meta?.tmuxSocket as string | undefined;
80
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for compact");
81
+ const command = opts?.instructions ? `/compact ${opts.instructions}` : "/compact";
82
+ await submitTextToTmux(session, command, socket);
83
+ return { method: "tmux-inject", command };
84
+ }
85
+
86
+ async clearContext(process: ManagedProcess): Promise<Record<string, unknown>> {
87
+ const session = process.meta?.tmuxSession as string | undefined;
88
+ const socket = process.meta?.tmuxSocket as string | undefined;
89
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for clear");
90
+ await submitTextToTmux(session, "/clear", socket);
91
+ return { method: "tmux-inject", command: "/clear" };
92
+ }
93
+
94
+ // #183/#184: parse the full Claude transcript into the shared SessionEvent stream. The
95
+ // runner slices per-segment, so we return the whole transcript's events each call.
96
+ async collectSessionEvents(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<SessionEvent[] | null> {
97
+ if (!ctx.transcriptPath) return null;
98
+ let jsonl: string;
99
+ try {
100
+ jsonl = await readFile(ctx.transcriptPath, "utf8");
101
+ } catch {
102
+ return null;
103
+ }
104
+ return collectClaudeSessionEvents(jsonl);
105
+ }
106
+
107
+ async collectSessionArchiveSegment(_process: ManagedProcess, ctx: { transcriptPath?: string }): Promise<string | null> {
108
+ if (!ctx.transcriptPath) return null;
109
+ try {
110
+ return await readFile(ctx.transcriptPath, "utf8");
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ async interrupt(process: ManagedProcess): Promise<Record<string, unknown>> {
117
+ const session = process.meta?.tmuxSession as string | undefined;
118
+ const socket = process.meta?.tmuxSocket as string | undefined;
119
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session to interrupt");
120
+ // The same ESC the web terminal's aux key sends: cancels the in-flight turn
121
+ // and drops Claude back to its input box without ending the session.
122
+ const result = Bun.spawnSync(tmuxCommand(socket, "send-keys", "-t", session, "Escape"), {
123
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
124
+ });
125
+ if (result.exitCode !== 0) {
126
+ const stderr = result.stderr.toString().trim();
127
+ throw new Error(`tmux interrupt failed: ${stderr || `exit code ${result.exitCode}`}`);
128
+ }
129
+ return { method: "tmux-escape" };
130
+ }
131
+
132
+ async probeActivity(process: ManagedProcess): Promise<"busy" | "idle" | "unknown"> {
133
+ const session = process.meta?.tmuxSession as string | undefined;
134
+ const socket = process.meta?.tmuxSocket as string | undefined;
135
+ if (!session || !tmuxHasSession(session, socket)) return "unknown";
136
+ return this.activityFromProbeOrPane(process, session, socket);
137
+ }
138
+
139
+ // Shared probe-primary / pane-fallback activity read (#766/#768), used by both the
140
+ // reconciler-facing probeActivity and the monitorless turn-watch. Primary: Claude
141
+ // Code's intrinsic session probe (busy/shell→busy — the idle-while-working gap the
142
+ // pane-scrape misread; idle→idle; waiting→unknown, a parked turn the backstop must
143
+ // not force-clear). Legacy fallback: scrape the tmux pane (pre-2.1.119 / probe missing).
144
+ private activityFromProbeOrPane(process: ManagedProcess, session: string, socket?: string): ClaudeProbeActivity {
145
+ const managed = readManagedClaudeStatus({
146
+ pid: process.pid,
147
+ tmuxSession: session,
148
+ tmuxSocket: socket,
149
+ configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
150
+ });
151
+ if (managed?.reading.available) return claudeProbeActivity(managed.reading.status);
152
+ let pane: string;
153
+ try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
154
+ if (claudePaneIsBusy(pane)) return "busy";
155
+ if (claudePaneLooksReady(pane)) return "idle";
156
+ return "unknown";
157
+ }
158
+
159
+ // #769 P1-A: an independent pane-busy read, consumed ONLY as a veto on the busy
160
+ // backstop's force-clear (BusyReconciler) — a probe-`idle` must never clear a live
161
+ // turn while the pane spinner still reads busy (a connection-retry/529 can write
162
+ // status:idle mid-turn without tripping the rate-limit pane detector — the #769
163
+ // gap). probeActivity stays probe-primary; this is a clear-time veto-toward-busy
164
+ // only. false on no session / capture failure (no veto).
165
+ async probePaneBusy(process: ManagedProcess): Promise<boolean> {
166
+ const session = process.meta?.tmuxSession as string | undefined;
167
+ const socket = process.meta?.tmuxSocket as string | undefined;
168
+ if (!session || !tmuxHasSession(session, socket)) return false;
169
+ try {
170
+ return claudePaneIsBusy(captureTmuxPane(session, socket));
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+
176
+ async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
177
+ const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
178
+ // A monitor object always exists for headless claude (it proxies to the runner
179
+ // control server), so its presence says nothing about whether a monitor process
180
+ // is actually connected. `monitorless` is set at spawn when the profile disables
181
+ // the relay plugin — then no monitor will ever connect, so we must not route
182
+ // through it (it would throw "no Claude monitor connected" forever); we inject via
183
+ // tmux instead. For monitored profiles we keep the monitor path (and its retry on
184
+ // transient startup races) to avoid double-delivering against a late monitor.
185
+ const monitorless = process.meta?.monitorless === true;
186
+ if (!monitorless && monitor?.deliver) {
187
+ await monitor.deliver(messages);
188
+ return;
189
+ }
190
+ // Inject straight into the interactive tmux session via send-keys — the same
191
+ // transport deliverInitialPrompt uses. Keeps the agent on interactive Claude (the
192
+ // user's Max quota), never `claude -p`/API, and lets isolated automation agents
193
+ // receive their task (a claimable message) and any mid-session messages (e.g. the
194
+ // budget "wrap up" warning) that a launch --prompt can't carry.
195
+ const session = process.meta?.tmuxSession as string | undefined;
196
+ const socket = process.meta?.tmuxSocket as string | undefined;
197
+ if (!session || !tmuxHasSession(session, socket)) {
198
+ throw new Error("Claude monitor delivery is unavailable and no tmux session for fallback");
199
+ }
200
+ await waitForClaudeInputReady(session, CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
201
+ // Monitorless = isolated profile with no relay plugin/CLI surface. Strip relay
202
+ // interaction scaffolding (reply reminder, claim-task instruction) — the agent has
203
+ // no way to /reply or /claim, so it's just confusing noise in a clean-room run.
204
+ await submitTextToTmux(session, claudeProviderMessageText(messages, { deliveryCount: 1, relaySurface: !monitorless }), socket);
205
+ // Monitorless = no Stop/UserPromptSubmit hooks, so the runner gets no turn
206
+ // lifecycle and never marks the claimed task done (the way Codex's app-server
207
+ // status does) — the task lingers until the runtime budget cancels it. Scrape
208
+ // the tmux pane for the busy→idle transition to synthesize a provider-turn
209
+ // signal so isolated automation agents reach task "done" like Codex.
210
+ if (monitorless) this.beginMonitorlessTurnWatch(process, session, socket);
211
+ }
212
+
213
+ private beginMonitorlessTurnWatch(process: ManagedProcess, sessionName: string, socketName?: string): void {
214
+ this.stopTurnWatch();
215
+ // #769 P2-C: feed the resolved claude pid into the provider-turn liveness pids
216
+ // so #650's pid-liveness reconcile arms for headless/monitorless turns too (the
217
+ // process itself isn't tracked here — pid is undefined for tmux-managed claude).
218
+ // A dead pid means the turn is definitively over, so this can't falsely clear.
219
+ const livenessPid = resolveClaudePid(process.pid, sessionName, socketName);
220
+ // We just submitted a prompt, so a turn is starting. Emit a synthetic
221
+ // provider-turn busy now so the runner arms task-claim completion; the claim
222
+ // for this delivery is already registered before deliver() runs.
223
+ this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(livenessPid ? { pids: [livenessPid] } : {}) });
224
+ let ticks = 0;
225
+ let idleStreak = 0;
226
+ let sawBusy = false;
227
+ this.turnWatcher = setInterval(() => {
228
+ ticks += 1;
229
+ if (!tmuxHasSession(sessionName, socketName) || ticks > CLAUDE_TURN_MAX_TICKS) {
230
+ // Session ended (the tmux watcher emits offline) or we hit the safety cap;
231
+ // stop without emitting idle and let budget/server reconcile resolve it.
232
+ this.stopTurnWatch();
233
+ return;
234
+ }
235
+ // #768: drive turn boundaries off the intrinsic probe (busy/shell→busy,
236
+ // idle→idle), falling back to the pane-scrape when the probe is unavailable.
237
+ // shell→busy means a Bash gate no longer reads as a finished turn.
238
+ const activity = this.activityFromProbeOrPane(process, sessionName, socketName);
239
+ if (activity === "busy") {
240
+ sawBusy = true;
241
+ idleStreak = 0;
242
+ return;
243
+ }
244
+ idleStreak = activity === "idle" ? idleStreak + 1 : 0;
245
+ const confirmedIdle = idleStreak >= CLAUDE_TURN_IDLE_CONFIRM_TICKS;
246
+ // Require either an observed busy spinner or a grace window, so we never
247
+ // declare idle in the brief gap before Claude starts rendering the turn.
248
+ const turnObserved = sawBusy || ticks >= CLAUDE_TURN_QUICK_GRACE_TICKS;
249
+ if (confirmedIdle && turnObserved) {
250
+ this.stopTurnWatch();
251
+ this.statusCb({ status: "idle", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
252
+ }
253
+ }, CLAUDE_TURN_POLL_MS);
254
+ }
255
+
256
+ private stopTurnWatch(): void {
257
+ if (this.turnWatcher) {
258
+ clearInterval(this.turnWatcher);
259
+ this.turnWatcher = undefined;
260
+ }
261
+ }
262
+
263
+ async deliverInitialPrompt(process: ManagedProcess, prompt: string, options?: { readyTimeoutMs?: number }): Promise<void> {
264
+ const session = process.meta?.tmuxSession as string | undefined;
265
+ const socket = process.meta?.tmuxSocket as string | undefined;
266
+ if (!session || !tmuxHasSession(session, socket)) throw new Error("no active tmux session for initial prompt");
267
+ await waitForClaudeInputReady(session, options?.readyTimeoutMs ?? CLAUDE_TMUX_READY_TIMEOUT_MS, socket);
268
+ await submitTextToTmux(session, prompt, socket);
269
+ }
270
+
271
+ buildSpawnArgs(config: RunnerSpawnConfig, providerConfig: ProviderConfig): SpawnArgs {
272
+ // Create + bootstrap + auth-link the isolated config home (no-op for host base) so the
273
+ // assembler's materialize step has somewhere to write the provisioned skills/plugins.
274
+ prepareClaudeProfileHome(config);
275
+ const defaultArgs = profileUsesClaudeHostProviderGlobals(config) ? providerConfig.defaultArgs : [];
276
+ // #557 — ONE assembler owns the provisioning/vanilla/instruction launch surface
277
+ // (--setting-sources, --plugin-dir, --mcp-config, status-line, --append-system-prompt);
278
+ // profile-projection describes the SAME assembled result, so report == launch. The
279
+ // adapter only adds the bits the assembler does not own: claude-rig command resolution,
280
+ // headless permission flags, --model, and the positional prompt. materializeLaunchAssembly
281
+ // writes the provisioned skills/plugins the assembled args point at.
282
+ const assembled = assembleLaunch("claude", config, providerConfig);
283
+ materializeLaunchAssembly("claude", config);
284
+ const isClaudeRig = /claude-rig/.test(providerConfig.command);
285
+ // Isolated profiles run their own CLAUDE_CONFIG_DIR and must never route through
286
+ // claude-rig — claude-rig resolves host rig config (and a bare `claude-rig` with no
287
+ // rig key just errors out). Only host-base profiles may use claude-rig.
288
+ const usesClaudeRig = isClaudeRig && profileUsesClaudeHostProviderGlobals(config);
289
+ const bypassRigDefaults = config.headless && usesClaudeRig && config.approvalMode !== "open";
290
+ const rigPrefix = usesClaudeRig && !bypassRigDefaults && config.rig ? ["launch", config.rig] : [];
291
+ const command = !usesClaudeRig || bypassRigDefaults || (!config.rig && !findClaudeRigRC(config.cwd))
292
+ ? "claude"
293
+ : providerConfig.command;
294
+ const providerArgs = config.headless
295
+ ? claudeLaunchArgs(defaultArgs, config.providerArgs, config.approvalMode)
296
+ : [...defaultArgs, ...config.providerArgs];
297
+ const args = [
298
+ ...rigPrefix,
299
+ ...assembled.args,
300
+ ...providerArgs,
301
+ ];
302
+ if (config.model) args.push("--model", config.model);
303
+ // #352: seed the prompt as Claude's positional arg for ALL launches (headless included) —
304
+ // `claude "<prompt>"` seeds the first turn via Claude's own input handling, immune to the
305
+ // send-keys/onboarding race that silently dropped headless spawns. Long/special-char briefs
306
+ // are shell-safe (shellEscape + launcher-script externalization in buildTmuxArgs).
307
+ if (config.prompt) args.push(String(config.prompt));
308
+ return {
309
+ command,
310
+ args,
311
+ cwd: config.cwd,
312
+ env: {
313
+ ...config.env,
314
+ // #557 — the assembler owns the isolated config-home env (CLAUDE_CONFIG_DIR).
315
+ ...assembled.env,
316
+ // Plain `claude` (isolated profiles) runs its own CLAUDE_CONFIG_DIR and never
317
+ // routes through claude-rig, so it never inherits the host's long-lived
318
+ // CLAUDE_CODE_OAUTH_TOKEN. Without it, claude falls back to the (often stale)
319
+ // file-based .credentials.json and bails to /login (401). claude-rig launches
320
+ // inject their own token, so only thread it for the plain-claude path, and
321
+ // never override an explicit per-launch value.
322
+ ...(command === "claude" && process.env.CLAUDE_CODE_OAUTH_TOKEN && !config.env?.CLAUDE_CODE_OAUTH_TOKEN
323
+ ? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN }
324
+ : {}),
325
+ AGENT_RELAY_PROVIDER: "claude",
326
+ ...(config.effort ? { CLAUDE_CODE_EFFORT_LEVEL: config.effort } : {}),
327
+ },
328
+ };
329
+ }
330
+
331
+ buildTmuxArgs(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): { sessionName: string; socketName: string; args: string[]; launcherScript: string } {
332
+ const sessionName = config.tmuxSession || tmuxSessionName(config.providerConfig.headless.tmuxPrefix, config.instanceId, config.label);
333
+ const socketName = tmuxSocketName(sessionName);
334
+ const shellCmd = [spawnArgs.command, ...spawnArgs.args].map(shellQuote).join(" ");
335
+ const tmuxArgs = ["new-session", "-d", "-s", sessionName, "-x", "200", "-y", "50"];
336
+
337
+ const envKeys = tmuxEnvKeys(spawnArgs.env, config.providerConfig.env);
338
+ const env: Record<string, string> = {};
339
+ for (const key of envKeys) if (spawnArgs.env[key] !== undefined) env[key] = spawnArgs.env[key]!;
340
+
341
+ // tmux new-session has a hard command-length limit that includes every argv item,
342
+ // including `-e KEY=value`. Keep tmux argv bounded by putting both env payloads
343
+ // (profile JSON, tokens, etc.) and the provider command/prompt in a session script.
344
+ const launcherScript = writeLauncherScript(sessionName, env, shellCmd);
345
+ tmuxArgs.push("-c", spawnArgs.cwd, `bash ${shellQuote(launcherScript)}`);
346
+ return { sessionName, socketName, args: tmuxArgs, launcherScript };
347
+ }
348
+
349
+ private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
350
+ const { sessionName, socketName, args: tmuxArgs, launcherScript } = this.buildTmuxArgs(config, spawnArgs);
351
+ this.modelUnavailableReported = false;
352
+ this.connectionRetryActive = false;
353
+ this.promptGatePaneState = initialClaudePromptGatePaneState();
354
+
355
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
356
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
357
+ });
358
+
359
+ const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
360
+ stdin: "ignore",
361
+ stdout: "pipe",
362
+ stderr: "pipe",
363
+ });
364
+
365
+ if (result.exitCode !== 0) {
366
+ const stderr = result.stderr.toString().trim();
367
+ throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
368
+ }
369
+
370
+ this.watchTmuxSession(sessionName, socketName);
371
+ const logFile = spawnArgs.env.AGENT_RELAY_LOG_FILE;
372
+ if (logFile) {
373
+ Bun.spawnSync(tmuxCommand(socketName, "pipe-pane", "-o", "-t", sessionName, `cat >> ${shellQuote(logFile)}`), {
374
+ stdin: "ignore",
375
+ stdout: "ignore",
376
+ stderr: "ignore",
377
+ });
378
+ }
379
+
380
+ return {
381
+ pid: undefined, process: undefined, meta: {
382
+ env: spawnArgs.env,
383
+ monitor: config.monitor,
384
+ // When the profile disables the relay plugin, the bundled monitor never
385
+ // loads, so no monitor will ever connect — deliver() must use tmux injection.
386
+ monitorless: !profileAllowsRelayFeature(config, "plugins"),
387
+ tmuxSession: sessionName,
388
+ tmuxSocket: socketName,
389
+ launcherScript,
390
+ },
391
+ };
392
+ }
393
+
394
+ private watchTmuxSession(sessionName: string, socketName?: string): void {
395
+ if (this.tmuxWatcher) clearInterval(this.tmuxWatcher);
396
+ this.tmuxWatcher = setInterval(() => {
397
+ if (!tmuxHasSession(sessionName, socketName)) {
398
+ clearInterval(this.tmuxWatcher!);
399
+ this.tmuxWatcher = undefined;
400
+ this.statusCb("offline");
401
+ return;
402
+ }
403
+ if (this.modelUnavailableReported) return;
404
+ let pane = "";
405
+ try {
406
+ pane = captureTmuxPane(sessionName, socketName);
407
+ } catch {
408
+ return;
409
+ }
410
+ const status = claudeModelUnavailableStatus(pane, sessionName);
411
+ if (status) {
412
+ this.modelUnavailableReported = true;
413
+ this.statusCb(status);
414
+ return;
415
+ }
416
+ // #769: connection-retry banner detection. Emits a transient blocked hold while the
417
+ // pane shows the retry footer; clears to idle when the banner disappears (API recovered).
418
+ const connRetryStatus = claudeConnectionRetryStatus(pane, sessionName);
419
+ if (connRetryStatus) {
420
+ if (!this.connectionRetryActive) {
421
+ this.connectionRetryActive = true;
422
+ this.statusCb(connRetryStatus);
423
+ }
424
+ } else if (this.connectionRetryActive) {
425
+ this.connectionRetryActive = false;
426
+ this.statusCb("idle");
427
+ }
428
+ // #663: prompt-gate fallback. Known gates should be prevented before launch;
429
+ // this catches missed/new blocking panes with the same #655 debounce + busy
430
+ // guard used for rate-limit holds.
431
+ const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
432
+ sessionName,
433
+ busy: claudePaneIsBusy(pane),
434
+ });
435
+ this.promptGatePaneState = promptGate.state;
436
+ if (promptGate.action) {
437
+ this.sendPromptGateKeystroke(sessionName, promptGate.action.keystroke, socketName);
438
+ if (promptGate.status) this.statusCb(promptGate.status);
439
+ }
440
+ }, 2000);
441
+ }
442
+
443
+ // Best-effort: a failed auto-dismiss must not wedge the pane watcher.
444
+ private sendPromptGateKeystroke(sessionName: string, keystroke: string, socketName?: string): void {
445
+ try {
446
+ Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, keystroke), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
447
+ } catch {
448
+ // ignore; the timeline/status still publishes, and the next watcher tick can observe whether the pane moved.
449
+ }
450
+ }
451
+
452
+ private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }, socketName?: string): Promise<void> {
453
+ this.stopTurnWatch();
454
+ if (this.tmuxWatcher) {
455
+ clearInterval(this.tmuxWatcher);
456
+ this.tmuxWatcher = undefined;
457
+ }
458
+
459
+ if (opts.graceful && tmuxHasSession(sessionName, socketName)) {
460
+ await submitTextToTmux(sessionName, CLAUDE_EXIT_COMMAND, socketName);
461
+
462
+ const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
463
+ while (Date.now() < deadline) {
464
+ if (!tmuxHasSession(sessionName, socketName)) return;
465
+ await Bun.sleep(200);
466
+ }
467
+ }
468
+
469
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
470
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
471
+ });
472
+ }
473
+ }
474
+
475
+ export const CLAUDE_EXIT_COMMAND = "/exit";
476
+ export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
477
+ export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
478
+ export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
479
+ // Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
480
+ const CLAUDE_TURN_WATCH_ID = "tmux-turn";
481
+ const CLAUDE_TURN_POLL_MS = 1500;
482
+ const CLAUDE_TURN_IDLE_CONFIRM_TICKS = 3; // ~4.5s of stable idle before declaring done
483
+ const CLAUDE_TURN_QUICK_GRACE_TICKS = 8; // ~12s fallback when the spinner was never caught
484
+ const CLAUDE_TURN_MAX_TICKS = 3600; // ~90min safety cap so the timer never leaks
485
+
486
+ export function claudeShutdownGraceMs(timeoutMs: number): number {
487
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
488
+ return Math.min(timeoutMs, 10_000);
489
+ }
490
+
491
+ // #557 — sessionStatusLineSettingsArgs now lives in ../launch-assembly (the single
492
+ // launch-arg home); re-exported so existing ./claude consumers + tests keep their path.
493
+ export { sessionStatusLineSettingsArgs };
494
+
495
+ export function claudeLaunchArgs(defaultArgs: string[], providerArgs: string[], approvalMode?: string): string[] {
496
+ const stripToolArgs = approvalMode === "read-only";
497
+ return [
498
+ ...stripClaudePermissionArgs(defaultArgs, stripToolArgs),
499
+ ...stripClaudePermissionArgs(providerArgs, stripToolArgs),
500
+ ...claudePermissionModeArgs(approvalMode),
501
+ ];
502
+ }
503
+
504
+ export function claudePermissionModeArgs(approvalMode?: string): string[] {
505
+ if (approvalMode === "open") return ["--dangerously-skip-permissions"];
506
+ if (approvalMode === "read-only") return ["--permission-mode", "dontAsk", "--tools", "Read,Grep,Glob,LS,Bash"];
507
+ return ["--permission-mode", "default"];
508
+ }
509
+
510
+ function stripClaudePermissionArgs(args: string[], stripToolArgs = false): string[] {
511
+ const result: string[] = [];
512
+ for (let i = 0; i < args.length; i++) {
513
+ const arg = args[i];
514
+ if (!arg) continue;
515
+ if (
516
+ arg === "--dangerously-skip-permissions" ||
517
+ arg === "--allow-dangerously-skip-permissions" ||
518
+ arg?.startsWith("--permission-mode=")
519
+ ) {
520
+ continue;
521
+ }
522
+ if (arg === "--permission-mode") {
523
+ i++;
524
+ continue;
525
+ }
526
+ if (stripToolArgs && /^--(?:tools|allowedTools|allowed-tools|disallowedTools|disallowed-tools)(?:=|$)/.test(arg)) {
527
+ if (!arg.includes("=")) i++;
528
+ continue;
529
+ }
530
+ result.push(arg);
531
+ }
532
+ return result;
533
+ }
534
+
535
+
536
+ export function tmuxSessionName(prefix: string, instanceId: string, label?: string): string {
537
+ if (label) return `${prefix}-${sanitizeFsName(label, { replacement: "-", collapse: false, lowercase: true })}`;
538
+ return `${prefix}-${instanceId.slice(0, 8)}`;
539
+ }
540
+
541
+ export function tmuxSocketName(sessionName: string): string {
542
+ return sanitizeFsName(`agent-relay-${sessionName}`, { replacement: "-", collapse: false, lowercase: true });
543
+ }
544
+
545
+ // Shared tmux helpers; tmuxHasSession re-exported for ./claude consumers + tests.
546
+ export { tmuxHasSession };
547
+
548
+ function captureTmuxPane(sessionName: string, socketName?: string): string {
549
+ const result = Bun.spawnSync(tmuxCommand(socketName, "capture-pane", "-p", "-t", sessionName, "-S", "-80"), {
550
+ stdin: "ignore", stdout: "pipe", stderr: "pipe",
551
+ });
552
+ if (result.exitCode !== 0) {
553
+ const stderr = result.stderr.toString().trim();
554
+ throw new Error(`tmux capture-pane failed: ${stderr || `exit code ${result.exitCode}`}`);
555
+ }
556
+ return result.stdout.toString();
557
+ }
558
+
559
+ // Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
560
+ // import paths (including claude.test.ts) continue to resolve via this module.
561
+ export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
562
+
563
+ async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
564
+ const deadline = Date.now() + timeoutMs;
565
+ while (Date.now() < deadline) {
566
+ if (!tmuxHasSession(sessionName, socketName)) throw new Error("tmux session exited before Claude became ready");
567
+ if (claudePaneLooksReady(captureTmuxPane(sessionName, socketName))) return;
568
+ await Bun.sleep(200);
569
+ }
570
+ throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
571
+ }
572
+
573
+ function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
574
+ const bufferName = `agent-relay-${crypto.randomUUID()}`;
575
+ const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
576
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
577
+ });
578
+ if (setResult.exitCode !== 0) {
579
+ const stderr = setResult.stderr.toString().trim();
580
+ throw new Error(`tmux set-buffer failed: ${stderr || `exit code ${setResult.exitCode}`}`);
581
+ }
582
+ try {
583
+ const pasteResult = Bun.spawnSync(tmuxCommand(socketName, "paste-buffer", "-b", bufferName, "-t", sessionName), {
584
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
585
+ });
586
+ if (pasteResult.exitCode !== 0) {
587
+ const stderr = pasteResult.stderr.toString().trim();
588
+ throw new Error(`tmux paste-buffer failed: ${stderr || `exit code ${pasteResult.exitCode}`}`);
589
+ }
590
+ } finally {
591
+ Bun.spawnSync(tmuxCommand(socketName, "delete-buffer", "-b", bufferName), {
592
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
593
+ });
594
+ }
595
+ }
596
+
597
+ async function submitTextToTmux(sessionName: string, text: string, socketName?: string): Promise<void> {
598
+ pasteTextIntoTmux(sessionName, text, socketName);
599
+ await Bun.sleep(CLAUDE_TMUX_SUBMIT_DELAY_MS);
600
+ const result = Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, CLAUDE_TMUX_SUBMIT_KEY), {
601
+ stdin: "ignore", stdout: "ignore", stderr: "pipe",
602
+ });
603
+ if (result.exitCode !== 0) {
604
+ const stderr = result.stderr.toString().trim();
605
+ throw new Error(`tmux submit failed: ${stderr || `exit code ${result.exitCode}`}`);
606
+ }
607
+ }
608
+
609
+ function writeLauncherScript(sessionName: string, env: Record<string, string>, shellCmd: string): string {
610
+ const scriptPath = launcherScriptPathForSession(sessionName);
611
+ const launcherDir = dirname(scriptPath); mkdirSync(launcherDir, { recursive: true, mode: 0o700 }); chmodSync(launcherDir, 0o700);
612
+ const exports = Object.entries(env)
613
+ .map(([key, value]) => `export ${shellEnvKey(key)}=${shellQuote(value)}`)
614
+ .join("\n");
615
+ writeFileSync(scriptPath, `#!/usr/bin/env bash\n${exports ? `${exports}\n` : ""}exec ${shellCmd}\n`, { mode: 0o700 });
616
+ return scriptPath;
617
+ }
618
+
619
+ function shellEnvKey(key: string): string {
620
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return key;
621
+ throw new Error(`invalid environment variable name for Claude launcher: ${key}`);
622
+ }
623
+
624
+ export function tmuxEnvKeys(env: Record<string, string>, providerEnv: Record<string, string>): string[] {
625
+ const keys = new Set<string>();
626
+ for (const key of Object.keys(env)) {
627
+ if (key.startsWith("AGENT_RELAY_") || key.startsWith("CLAUDE_")) {
628
+ keys.add(key);
629
+ }
630
+ }
631
+ for (const key of Object.keys(providerEnv)) {
632
+ keys.add(key);
633
+ }
634
+ return [...keys].sort();
635
+ }
636
+
637
+ // Shared shell-quoting; re-exported so `./claude` consumers + tests resolve it.
638
+ export { shellQuote };
639
+
640
+ export function findClaudeRigRC(cwd: string): string | null {
641
+ const home = homedir();
642
+ let dir = resolve(cwd);
643
+ while (true) {
644
+ // Stop at $HOME without inspecting it: ~/.claude-rig is the claude-rig tool's
645
+ // own config home, not a per-project rc marker. Matching it would make every
646
+ // project under $HOME look like a claude-rig project.
647
+ if (dir === home) return null;
648
+ const rc = join(dir, ".claude-rig");
649
+ if (existsSync(rc)) return rc;
650
+ const parent = resolve(dir, "..");
651
+ if (parent === dir) return null;
652
+ dir = parent;
653
+ }
654
+ }
655
+
656
+ async function terminateSingleProcess(process: ManagedProcess, opts: { graceful: boolean; timeoutMs: number }): Promise<void> {
657
+ const proc = process.process;
658
+ if (!proc) return;
659
+ try {
660
+ proc.kill(opts.graceful ? "SIGTERM" : "SIGKILL");
661
+ } catch {
662
+ return;
663
+ }
664
+ const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), opts.timeoutMs));
665
+ const result = await Promise.race([proc.exited, timeout]);
666
+ if (result === "timeout") {
667
+ try {
668
+ proc.kill("SIGKILL");
669
+ } catch {}
670
+ await proc.exited.catch(() => {});
671
+ }
672
+ }