agent-relay-runner 0.129.11 → 0.129.13

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.
@@ -1,7 +1,6 @@
1
1
  import { resolve } from "node:path";
2
- import { isRecord, type ProviderState } from "agent-relay-sdk";
3
- import type { ProviderStatusUpdate, RunnerSpawnConfig } from "../adapter";
4
- import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import type { PaneObservation, RunnerSpawnConfig } from "../adapter";
5
4
 
6
5
  export interface ClaudeConfigPromptGatePrevention {
7
6
  id: string;
@@ -15,16 +14,28 @@ export interface ClaudeLaunchPromptGatePrevention {
15
14
  apply(config: RunnerSpawnConfig): { strictMcpConfig?: boolean };
16
15
  }
17
16
 
18
- type PaneGateMatch = { message?: string; resetAt?: number; kind?: "usage_limit" | "transient_overload"; errorType?: string };
17
+ type PaneGateMatch = { message?: string };
18
+
19
+ /**
20
+ * #1651 — how much authority a pane match is allowed to carry.
21
+ *
22
+ * `advisory` is the only correct value for a gate detected purely from TUI geometry. It
23
+ * produces a {@link PaneObservation} and NOTHING else: no keystroke (the concept no longer
24
+ * exists in this file), no ProviderStatusUpdate, hence no status, no `clear`, no
25
+ * providerState. #1611/#1615/#1621/#1637/#1642 each tried to make one of these gates safe by
26
+ * tightening its string matcher; the matcher was never the problem — granting a string match
27
+ * the authority to mutate state was.
28
+ *
29
+ * The retired usage-limit pane gate (#1664) is deliberately not represented here: provider
30
+ * state comes from structured provider failures, never a pane scrape.
31
+ */
19
32
 
20
33
  interface ClaudePanePromptGate {
21
34
  id: string;
22
35
  label: string;
23
36
  policy: string;
24
- keystroke: string;
25
37
  minDetections: number;
26
- match(text: string, nowMs?: number): PaneGateMatch | null;
27
- providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): ProviderState;
38
+ match(text: string): PaneGateMatch | null;
28
39
  }
29
40
 
30
41
  export interface ClaudePromptGatePaneState {
@@ -40,17 +51,11 @@ export interface ClaudePromptGatePaneContext {
40
51
  sourceId?: string;
41
52
  }
42
53
 
43
- export interface ClaudePromptGateAction {
44
- gateId: string;
45
- label: string;
46
- policy: string;
47
- keystroke: string;
48
- source: string;
49
- }
50
-
51
54
  export interface ClaudePromptGatePaneResult {
52
- action?: ClaudePromptGateAction;
53
- status?: ProviderStatusUpdate;
55
+ /** #1651 — always present when a gate reached minDetections. Diagnostic only. */
56
+ observation?: PaneObservation;
57
+ /** Kept for callers which assert absence; `never` makes a status impossible to emit. */
58
+ status?: never;
54
59
  state: ClaudePromptGatePaneState;
55
60
  }
56
61
 
@@ -101,28 +106,23 @@ export const claudeLaunchPromptGatePreventions: ClaudeLaunchPromptGatePrevention
101
106
  ];
102
107
 
103
108
  const panePromptGates: ClaudePanePromptGate[] = [
104
- {
105
- id: "usage-limit",
106
- label: "Usage limit",
107
- policy: "wait-for-reset",
108
- keystroke: "Escape",
109
- minDetections: 2,
110
- match: (text, nowMs) => parseClaudeRateLimitPane(text, nowMs),
111
- providerState(match, context) {
112
- return buildRateLimitProviderState({
113
- errorType: match.errorType ?? "session_limit",
114
- kind: match.kind ?? "usage_limit",
115
- ...(match.resetAt ? { resetAt: match.resetAt } : {}),
116
- message: match.message,
117
- source: paneSource(context),
118
- });
119
- },
120
- },
109
+ // #1651 — the five gates below are ADVISORY. Each used to type a key into a live pane the
110
+ // instant `claudePaneIsBusy` happened to read false, then publish a fabricated
111
+ // idle + clear:["subagent"]. Two independent hazards, both realized:
112
+ // - the four Enter gates SUBMIT whatever text is already sitting in the composer;
113
+ // - project-mcp-discovery's Escape CANCELS a live turn.
114
+ // Neither needed the matcher to be wrong. `claudePaneIsBusy` is a last-ten-lines scrape
115
+ // whose only busy witness sits on the very last budgeted line, so one extra row of bottom
116
+ // chrome (a wrapped advisory, a two-line composer, one more trailing blank) flips it to
117
+ // false ON A LIVE TURN — measured on CC 2.1.220. A persistent ready-looking footer is not
118
+ // an authorization boundary, so none of these may hold one.
119
+ // Real startup modals are already prevented structurally, before launch, by
120
+ // claudeConfigPromptGatePreventions / claudeLaunchPromptGatePreventions above; these
121
+ // entries are the backstop that NOTICES a prevention missed, not a second actuator.
121
122
  {
122
123
  id: "workspace-trust",
123
124
  label: "Workspace trust",
124
125
  policy: "accept-trusted-workspace",
125
- keystroke: "Enter",
126
126
  minDetections: 2,
127
127
  match: (text) => /do you trust (?:the files in )?(?:this folder|this project|this directory)/i.test(text)
128
128
  && /\b(?:yes|proceed|trust|continue)\b/i.test(text)
@@ -133,7 +133,6 @@ const panePromptGates: ClaudePanePromptGate[] = [
133
133
  id: "bypass-permissions-mode",
134
134
  label: "Bypass permissions mode",
135
135
  policy: "accept-configured-open-approval",
136
- keystroke: "Enter",
137
136
  minDetections: 2,
138
137
  match: (text) => /bypass permissions mode/i.test(text) && /\b(?:accept|proceed|continue|yes)\b/i.test(text)
139
138
  ? { message: lineMatching(text, /bypass permissions mode/i) }
@@ -143,7 +142,6 @@ const panePromptGates: ClaudePanePromptGate[] = [
143
142
  id: "project-mcp-discovery",
144
143
  label: "Project MCP discovery",
145
144
  policy: "reject-ambient-project-mcp",
146
- keystroke: "Escape",
147
145
  minDetections: 2,
148
146
  match: (text) => /\bnew MCP servers? found\b/i.test(text) && /\b(?:select|enable|servers?)\b/i.test(text)
149
147
  ? { message: lineMatching(text, /new MCP servers? found/i) }
@@ -153,7 +151,6 @@ const panePromptGates: ClaudePanePromptGate[] = [
153
151
  id: "theme-selection",
154
152
  label: "Theme selection",
155
153
  policy: "accept-default-theme",
156
- keystroke: "Enter",
157
154
  minDetections: 2,
158
155
  match: (text) => /choose (?:the )?(?:text style|theme).*Claude Code/i.test(text) && /\b(?:dark|light) mode\b/i.test(text)
159
156
  ? { message: lineMatching(text, /choose/i) }
@@ -163,12 +160,22 @@ const panePromptGates: ClaudePanePromptGate[] = [
163
160
  id: "first-run-onboarding",
164
161
  label: "First-run onboarding",
165
162
  policy: "accept-onboarding",
166
- keystroke: "Enter",
167
163
  minDetections: 2,
168
164
  match: (text) => /welcome to Claude Code/i.test(text) && /\b(?:let'?s get started|continue|get started)\b/i.test(text)
169
165
  ? { message: lineMatching(text, /welcome to Claude Code/i) }
170
166
  : null,
171
167
  },
168
+ // #1514 (review finding 3) — the "Exit anyway / Move to background / Stay"
169
+ // confirmation is deliberately NOT a live pane-watcher gate. Auto-pressing Enter
170
+ // on a session that did NOT initiate shutdown is unsafe: it could submit stray
171
+ // input or dismiss an unrelated modal on an attached/live session. This dialog is
172
+ // only ever the right thing to answer under an explicit Relay shutdown, so it is
173
+ // handled solely by the runner's shutdown grace loop (confirmClaudeExitDialogUnderGrant
174
+ // in claude-exit.ts) — which runs only after this runner sent `/exit` under a one-shot
175
+ // grant. #1647 made that structural rather than advisory: this file cannot even import
176
+ // the exit writers (claude-exit.guard.test.ts fails the build if it ever does). The
177
+ // orphan safety net for a session already wedged mid-shutdown is the orchestrator's
178
+ // wedged-session reaper, not a keypress into a live pane.
172
179
  ];
173
180
 
174
181
  export function applyClaudeConfigPromptGatePreventions(
@@ -196,7 +203,7 @@ export function evaluateClaudePromptGatePane(
196
203
  state: ClaudePromptGatePaneState,
197
204
  context: ClaudePromptGatePaneContext = {},
198
205
  ): ClaudePromptGatePaneResult {
199
- const detected = firstDetectedPaneGate(text, context.nowMs);
206
+ const detected = firstDetectedPaneGate(text);
200
207
  if (!detected) return { state: initialClaudePromptGatePaneState() };
201
208
  if (context.busy) {
202
209
  return { state: { ...state, currentGateId: undefined, consecutiveDetections: 0 } };
@@ -213,16 +220,18 @@ export function evaluateClaudePromptGatePane(
213
220
  };
214
221
  if (consecutiveDetections < detected.entry.minDetections) return { state: nextState };
215
222
 
216
- const action: ClaudePromptGateAction = {
217
- gateId: detected.entry.id,
218
- label: detected.entry.label,
219
- policy: detected.entry.policy,
220
- keystroke: detected.entry.keystroke,
221
- source: paneSource(context),
223
+ const observedAt = context.nowMs ?? Date.now();
224
+ const observation: PaneObservation = {
225
+ eventType: "claude.pane_gate_candidate",
226
+ source: "pane",
227
+ detectorId: detected.entry.id,
228
+ observedAt,
229
+ ...(context.sessionName ? { sessionName: context.sessionName } : {}),
230
+ ...(detected.match.message ? { message: detected.match.message } : {}),
231
+ detail: { label: detected.entry.label, policy: detected.entry.policy, paneSource: paneSource(context) },
222
232
  };
223
233
  return {
224
- action,
225
- status: promptGateStatus(detected.entry, detected.match, action, context),
234
+ observation,
226
235
  state: { ...nextState, handledGateId: detected.entry.id },
227
236
  };
228
237
  }
@@ -232,43 +241,14 @@ function paneSource(context: ClaudePromptGatePaneContext): string {
232
241
  return context.sessionName ? `${source}:${context.sessionName}` : source;
233
242
  }
234
243
 
235
- function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
244
+ function firstDetectedPaneGate(text: string): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
236
245
  for (const entry of panePromptGates) {
237
- const match = entry.match(text, nowMs);
246
+ const match = entry.match(text);
238
247
  if (match) return { entry, match };
239
248
  }
240
249
  return null;
241
250
  }
242
251
 
243
- function promptGateStatus(
244
- entry: ClaudePanePromptGate,
245
- match: PaneGateMatch,
246
- action: ClaudePromptGateAction,
247
- context: ClaudePromptGatePaneContext,
248
- ): ProviderStatusUpdate {
249
- const metadata = {
250
- eventType: "claude.prompt_gate.auto_handled",
251
- gateId: action.gateId,
252
- policy: action.policy,
253
- keystroke: action.keystroke,
254
- source: action.source,
255
- ...(context.sessionName ? { sessionName: context.sessionName } : {}),
256
- };
257
- return {
258
- status: "idle",
259
- clear: ["subagent"],
260
- ...(entry.providerState ? { providerState: entry.providerState(match, context) } : {}),
261
- timeline: {
262
- status: "claude.prompt_gate.auto_handled",
263
- id: `claude-prompt-gate-${entry.id}-${Date.now()}`,
264
- timestamp: Date.now(),
265
- title: "Claude prompt gate auto-handled",
266
- body: match.message ?? `${entry.label} dismissed with ${entry.keystroke}`,
267
- metadata,
268
- },
269
- };
270
- }
271
-
272
252
  function lineMatching(text: string, pattern: RegExp): string | undefined {
273
253
  return text.split(/\r?\n/).map((line) => line.trim()).find((line) => pattern.test(line));
274
254
  }
@@ -176,6 +176,7 @@ export class ClaudeSessionCapture {
176
176
  ...(step.occurredAt ? { occurredAt: step.occurredAt } : {}),
177
177
  ...(turnId ? { turnId } : {}),
178
178
  ...(step.label ? { label: step.label } : {}),
179
+ ...(step.category ? { category: step.category } : {}),
179
180
  });
180
181
  if (step.type === "narration") emittedNarrationKeys.add(sig);
181
182
  }
@@ -187,9 +188,18 @@ export class ClaudeSessionCapture {
187
188
  pendingPoll = next.catch(() => {});
188
189
  return next;
189
190
  };
190
- this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(() => { void poll(); }, REASONING_POLL_MS) };
191
+ // The serialized pendingPoll deliberately catches so later polls may continue, but poll()
192
+ // still returns the original rejection to its caller. Both timer-driven calls below have no
193
+ // caller, so catch them here rather than turning a rotating transcript into a process-fatal
194
+ // unhandled rejection.
195
+ const pollInBackground = (): void => {
196
+ void poll().catch((error) => {
197
+ try { ctx.log(`reasoning tail poll failed: ${errMessage(error)}`); } catch {}
198
+ });
199
+ };
200
+ this.reasoningTail = { emittedNarrationKeys, poll, timer: setInterval(pollInBackground, REASONING_POLL_MS) };
191
201
  ctx.log(`reasoning tail started (turn ${turnIdAtStart ?? "?"})`);
192
- void poll();
202
+ pollInBackground();
193
203
  }
194
204
 
195
205
  private rememberEmittedStepKey(sig: string): void {
@@ -232,6 +242,7 @@ export function providerSessionEventToMessageSession(event: ProviderSessionEvent
232
242
  origin: event.origin ?? "provider",
233
243
  ...(event.turnId ? { turnId: event.turnId } : {}),
234
244
  ...(event.label ? { label: event.label } : {}),
245
+ ...(event.category ? { toolCategory: event.category } : {}),
235
246
  ...(event.status ? { status: event.status } : {}),
236
247
  ...(event.streaming !== undefined ? { streaming: event.streaming } : {}),
237
248
  ...(event.stepId ? { stepId: event.stepId } : {}),
@@ -19,6 +19,7 @@ import { homedir } from "node:os";
19
19
  import { join } from "node:path";
20
20
  import { isPidAlive } from "agent-relay-sdk/process-utils";
21
21
  import { tmuxCommand } from "agent-relay-sdk/tmux-utils";
22
+ import { claudeProbeBusyStaleMsFromEnv } from "../config";
22
23
 
23
24
  /** Raw probe status; "unknown" = absent / unparseable / version-gated out. */
24
25
  export type ClaudeProbeStatus = "busy" | "shell" | "idle" | "waiting" | "unknown";
@@ -29,6 +30,56 @@ export type ClaudeProbeActivity = "busy" | "idle" | "unknown";
29
30
  /** First Claude Code version that writes the session probe. Below this → fallback. */
30
31
  export const CLAUDE_PROBE_MIN_VERSION = "2.1.119";
31
32
 
33
+ /**
34
+ * #1621 — True when a probe's `busy`/`shell` assertion is too old to be trusted on its own.
35
+ *
36
+ * Claude Code writes this file only on a status TRANSITION, so a cancellation that produces no
37
+ * transition (an ESC typed into the pane by a human, or relay's OWN prompt-gate Escape) freezes it
38
+ * on the pre-cancel status. `busy` then becomes an unfalsifiable assertion: the busy backstop
39
+ * (BusyReconciler) resets its idle streak on every 4s poll and the provider-turn claim is never
40
+ * cleared — observed live at 2h25m and counting, ended only by a human.
41
+ *
42
+ * This is a TRUST window, not a busy timeout: past it the probe is merely no longer allowed to
43
+ * overrule an input-ready pane (claudeActivityFromProbeAndPane). Long single-tool turns
44
+ * legitimately hold a frozen probe for many minutes and keep reading busy because the pane keeps
45
+ * rendering its activity footer — the #766 protection is the pane, not the clock.
46
+ *
47
+ * A probe that predates `updatedAt` (older schema) can't be aged, so it keeps today's
48
+ * unconditional trust: uncertainty resolves toward holding the turn, never toward clearing it.
49
+ */
50
+ export function claudeProbeBusyIsStale(
51
+ reading: ClaudeSessionReading,
52
+ nowMs: number = Date.now(),
53
+ thresholdMs: number = claudeProbeBusyStaleMsFromEnv(),
54
+ ): boolean {
55
+ if (!reading.available || reading.updatedAt === null) return false;
56
+ return nowMs - reading.updatedAt >= thresholdMs;
57
+ }
58
+
59
+ /**
60
+ * The probe half of an activity read: the mapped 3-valued activity (undefined when the probe is
61
+ * absent/old/unparseable → the caller falls back to the pane) plus whether a `busy` verdict has
62
+ * aged out of its trust window (#1621). Returned together because the two are always consumed
63
+ * together — `busyStale` is meaningless for anything but a busy verdict.
64
+ */
65
+ export function claudeProbeVerdict(
66
+ reading: ClaudeSessionReading | undefined,
67
+ nowMs: number = Date.now(),
68
+ opts: { paneInterruptAt?: number } = {},
69
+ ): { activity: ClaudeProbeActivity | undefined; busyStale: boolean } {
70
+ const activity = reading?.available ? claudeProbeActivity(reading.status) : undefined;
71
+ if (activity !== "busy" || !reading) return { activity, busyStale: false };
72
+ // #1621 — the precise form of the same staleness question, for the case relay CAUSED: relay
73
+ // typed a turn-cancelling keystroke into the pane (the prompt gate's Escape) and the probe has
74
+ // not been rewritten since. That is not a guess about elapsed time — it is a busy assertion that
75
+ // demonstrably predates the cancellation, so it stops being authoritative immediately instead of
76
+ // after the whole trust window. The pane still has to corroborate the idle read either way.
77
+ const predatesInterrupt = opts.paneInterruptAt !== undefined
78
+ && reading.updatedAt !== null
79
+ && reading.updatedAt <= opts.paneInterruptAt;
80
+ return { activity, busyStale: predatesInterrupt || claudeProbeBusyIsStale(reading, nowMs) };
81
+ }
82
+
32
83
  export interface ClaudeSessionReading {
33
84
  /** Raw probe status, or "unknown" when the probe is absent/old/unparseable. */
34
85
  status: ClaudeProbeStatus;
@@ -40,11 +91,15 @@ export interface ClaudeSessionReading {
40
91
  procStart: string | null;
41
92
  /** `updatedAt` epoch-ms of the last probe write, or null. */
42
93
  updatedAt: number | null;
94
+ /** Claude's own session UUID (#1235) — names the transcript file. Null when absent. */
95
+ sessionId: string | null;
96
+ /** The session's cwd (#1235) — slugified into the transcript's project dir. Null when absent. */
97
+ cwd: string | null;
43
98
  /** True iff a present, parseable, version-gated probe produced `status`. */
44
99
  available: boolean;
45
100
  }
46
101
 
47
- const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, available: false };
102
+ const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, sessionId: null, cwd: null, available: false };
48
103
 
49
104
  /**
50
105
  * Process-liveness seam for the staleness guard (#769 P1-B), injectable in tests.
@@ -66,7 +121,10 @@ export interface ClaudeProbeLiveness {
66
121
  // `updatedAt` for its whole duration (verified: a 90s+ busy turn never rewrote it).
67
122
  // A staleness threshold would force a long busy turn back to the pane-scrape and
68
123
  // reintroduce the #766 idle-while-working bug; pid-liveness + procStart cover the
69
- // SIGKILL-frozen and pid-reuse cases without that hazard.
124
+ // SIGKILL-frozen and pid-reuse cases without that hazard. (#1621 does age the busy
125
+ // VERDICT out of authority — see claudeProbeBusyIsStale — but never the reading
126
+ // itself, and only ever in favour of a positively input-ready pane. This invariant
127
+ // stands: `available` is not time-gated.)
70
128
  const DEFAULT_LIVENESS: ClaudeProbeLiveness = {
71
129
  procStartOf: readProcStartTime,
72
130
  pidAlive: isPidAlive,
@@ -129,11 +187,13 @@ export function parseClaudeSessionProbe(raw: string, minVersion = CLAUDE_PROBE_M
129
187
  const version = typeof probe.version === "string" ? probe.version : null;
130
188
  const procStart = typeof probe.procStart === "string" ? probe.procStart : null;
131
189
  const updatedAt = typeof probe.updatedAt === "number" ? probe.updatedAt : null;
132
- if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt };
190
+ const sessionId = typeof probe.sessionId === "string" && probe.sessionId.trim() ? probe.sessionId : null;
191
+ const cwd = typeof probe.cwd === "string" && probe.cwd.trim() ? probe.cwd : null;
192
+ if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt, sessionId, cwd };
133
193
  const status = normalizeStatus(probe.status);
134
- if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, available: false };
194
+ if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, sessionId, cwd, available: false };
135
195
  const waitingFor = typeof probe.waitingFor === "string" && probe.waitingFor.trim() ? probe.waitingFor : null;
136
- return { status, waitingFor, version, procStart, updatedAt, available: true };
196
+ return { status, waitingFor, version, procStart, updatedAt, sessionId, cwd, available: true };
137
197
  }
138
198
 
139
199
  /**
@@ -195,11 +255,42 @@ export function readManagedClaudeStatus(args: {
195
255
  tmuxSession?: string;
196
256
  tmuxSocket?: string;
197
257
  configHomeEnv?: string;
198
- }): { reading: ClaudeSessionReading; pid: number } | undefined {
258
+ }): { reading: ClaudeSessionReading; pid: number; configHome: string } | undefined {
199
259
  const pid = resolveClaudePid(args.pid, args.tmuxSession, args.tmuxSocket);
200
260
  if (pid === undefined) return undefined;
201
261
  const configHome = args.configHomeEnv || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
202
- return { reading: readClaudeSessionStatus(configHome, pid), pid };
262
+ return { reading: readClaudeSessionStatus(configHome, pid), pid, configHome };
263
+ }
264
+
265
+ /**
266
+ * The transcript JSONL path Claude Code writes for a session (#1235). Claude stores
267
+ * per-cwd transcripts at `<configHome>/projects/<cwd-slug>/<sessionId>.jsonl`, where
268
+ * the slug replaces every non-alphanumeric char in the absolute cwd with `-` (verified
269
+ * live on CC 2.1.205: `/home/u/p/.agent-relay/x` → `-home-u-p--agent-relay-x`). This is
270
+ * the hookless path the monitorless turn-watch feeds to the transcript reasoning tail so
271
+ * isolated/lean Claude workers (no Stop/UserPromptSubmit hooks) still mirror chat.
272
+ */
273
+ export function claudeTranscriptPath(configHome: string, cwd: string, sessionId: string): string {
274
+ return join(configHome, "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-"), `${sessionId}.jsonl`);
275
+ }
276
+
277
+ /**
278
+ * Resolve the live transcript path for the managed claude via its session probe (#1235):
279
+ * the probe carries `sessionId` + `cwd`, and the runner knows the config home. Returns
280
+ * undefined when the probe is absent/old (no sessionId/cwd) or the pid can't be resolved,
281
+ * which leaves the caller with no native capture (graceful degrade to today's behavior).
282
+ */
283
+ export function resolveManagedClaudeTranscriptPath(args: {
284
+ pid?: number;
285
+ tmuxSession?: string;
286
+ tmuxSocket?: string;
287
+ configHomeEnv?: string;
288
+ }): string | undefined {
289
+ const managed = readManagedClaudeStatus(args);
290
+ if (!managed) return undefined;
291
+ const { sessionId, cwd } = managed.reading;
292
+ if (!sessionId || !cwd) return undefined;
293
+ return claudeTranscriptPath(managed.configHome, cwd, sessionId);
203
294
  }
204
295
 
205
296
  /**
@@ -0,0 +1,149 @@
1
+ // #1647 — the Claude tmux shutdown sequence: observe, correlate, and only then
2
+ // (maybe) exit gracefully. Split out of ./claude at the #300 ratchet; the adapter
3
+ // keeps the watcher teardown and delegates the rest here.
4
+ //
5
+ // The policy itself lives in ./claude-exit. This module is the caller that samples
6
+ // live state and drives the loop.
7
+ import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
8
+ import type { ManagedProcess } from "../adapter";
9
+ import { readManagedClaudeStatus } from "./claude-session-probe";
10
+ import { claudePaneIsBusy } from "./claude-status-detectors";
11
+ import { captureTmuxPane } from "./claude-tmux";
12
+ import { authorizeClaudeGracefulExit, claudeExitDenialIsPermanent, confirmClaudeExitDialogUnderGrant, mintClaudeShutdownIntent, submitClaudeExitCommand } from "./claude-exit";
13
+ import type { ClaudeExitGrant, ClaudeExitObservation, ClaudeTurnState } from "./claude-exit";
14
+
15
+ export function claudeShutdownGraceMs(timeoutMs: number): number {
16
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
17
+ return Math.min(timeoutMs, 10_000);
18
+ }
19
+
20
+ // The turn identity a shutdown intent is correlated against. Claude's intrinsic probe
21
+ // has no turn id, so the pair (sessionId, procStart) is the finest-grained identity
22
+ // available: it changes on any restart or session swap, which is exactly the
23
+ // "different process than the one you asked me to stop" case the token must catch.
24
+ export function claudeTurnKey(sessionId: string | null, procStart: string | null): string {
25
+ return `${sessionId ?? "?"}:${procStart ?? "?"}`;
26
+ }
27
+
28
+ /**
29
+ * Sample the facts a graceful exit must correlate against.
30
+ *
31
+ * Turn state is deliberately fail-closed: it reads "ended" ONLY on positive evidence
32
+ * from the intrinsic probe (Claude's own status file, which also carries the sessionId
33
+ * and procStart that bind this reading to this exact process). The pane scrape is kept
34
+ * as a VETO — it can turn "ended" into "live", never the reverse — because
35
+ * `claudePaneIsBusy` alone is exactly what false-negatived while the killed coordinator
36
+ * was mid-stream. No probe, no exit.
37
+ */
38
+ export function observeClaudeExitState(
39
+ process: ManagedProcess,
40
+ sessionName: string,
41
+ socketName: string | undefined,
42
+ fallbackEpoch: number,
43
+ ): ClaudeExitObservation {
44
+ const managed = readManagedClaudeStatus({
45
+ pid: process.pid,
46
+ tmuxSession: sessionName,
47
+ tmuxSocket: socketName,
48
+ configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
49
+ });
50
+ const reading = managed?.reading;
51
+ const probeIdle = reading?.available === true && reading.status === "idle";
52
+ let turn: ClaudeTurnState = probeIdle ? "ended" : reading?.available === true ? "live" : "unknown";
53
+ if (turn === "ended") {
54
+ try {
55
+ if (claudePaneIsBusy(captureTmuxPane(sessionName, socketName))) turn = "live";
56
+ } catch {
57
+ turn = "unknown"; // A pane we cannot read is not a pane we may type into.
58
+ }
59
+ }
60
+ return {
61
+ epoch: (process.meta?.providerEpoch as number | undefined) ?? fallbackEpoch,
62
+ ...(process.pid === undefined ? {} : { pid: process.pid }),
63
+ procStart: reading?.procStart ?? null,
64
+ sessionName,
65
+ providerSessionId: reading?.sessionId ?? null,
66
+ turn,
67
+ turnKey: claudeTurnKey(reading?.sessionId ?? null, reading?.procStart ?? null),
68
+ now: Date.now(),
69
+ };
70
+ }
71
+
72
+ // Injectable seams so the shutdown loop (which otherwise only talks to a live tmux
73
+ // server) is unit-testable — same pattern as ClaudeClearCommitDeps.
74
+ export interface ClaudeShutdownDeps {
75
+ hasSession?: (sessionName: string, socketName?: string) => boolean;
76
+ observe?: (process: ManagedProcess, sessionName: string, socketName?: string) => ClaudeExitObservation;
77
+ submitExit?: (grant: ClaudeExitGrant) => Promise<unknown>;
78
+ confirmExit?: (grant: ClaudeExitGrant) => unknown;
79
+ reap?: (sessionName: string, socketName?: string) => void;
80
+ sleep?: (ms: number) => Promise<unknown>;
81
+ }
82
+
83
+ export async function shutdownClaudeTmux(
84
+ process: ManagedProcess,
85
+ sessionName: string,
86
+ opts: { graceful: boolean; timeoutMs: number },
87
+ socketName: string | undefined,
88
+ fallbackEpoch: number,
89
+ deps: ClaudeShutdownDeps = {},
90
+ ): Promise<void> {
91
+ const hasSession = deps.hasSession ?? tmuxHasSession;
92
+ const observe = deps.observe ?? ((p, s, k) => observeClaudeExitState(p, s, k, fallbackEpoch));
93
+ const submitExit = deps.submitExit ?? submitClaudeExitCommand;
94
+ const confirmExit = deps.confirmExit ?? confirmClaudeExitDialogUnderGrant;
95
+ const sleep = deps.sleep ?? Bun.sleep;
96
+
97
+ if (opts.graceful && hasSession(sessionName, socketName)) {
98
+ // The audited lifecycle request, pinned to the process it was issued against.
99
+ // Everything below is gated on it; nothing here can be reached from a detector.
100
+ const observed = observe(process, sessionName, socketName);
101
+ const intent = mintClaudeShutdownIntent({
102
+ epoch: observed.epoch,
103
+ ...(observed.pid === undefined ? {} : { pid: observed.pid }),
104
+ procStart: observed.procStart,
105
+ sessionName,
106
+ providerSessionId: observed.providerSessionId,
107
+ turnKey: observed.turnKey,
108
+ });
109
+
110
+ let grant: ClaudeExitGrant | undefined;
111
+ const deadline = Date.now() + claudeShutdownGraceMs(opts.timeoutMs);
112
+ while (Date.now() < deadline) {
113
+ if (!hasSession(sessionName, socketName)) return;
114
+ if (!grant) {
115
+ // #1647 — the guard that would have saved the coordinator. A turn that is live
116
+ // (or that we cannot positively confirm has ended) authorizes nothing, so we
117
+ // keep waiting and let the out-of-band reap below terminate the process if the
118
+ // turn never settles.
119
+ const decision = authorizeClaudeGracefulExit(intent, observe(process, sessionName, socketName), socketName);
120
+ if (decision.authorized) {
121
+ grant = decision.grant;
122
+ await submitExit(grant);
123
+ } else if (claudeExitDenialIsPermanent(decision.reason)) {
124
+ // Nothing this loop can observe later will change the verdict — waiting out
125
+ // the grace window would only delay the reap. Terminate out of band now.
126
+ break;
127
+ }
128
+ } else {
129
+ // `/exit` while a background task is running raises Claude's "Exit anyway /
130
+ // Move to background / Stay" confirmation (#1514). Answering it is allowed ONLY
131
+ // through the same grant that submitted that `/exit`, and only for a dialog that
132
+ // was not already on screen beforehand.
133
+ confirmExit(grant);
134
+ }
135
+ await sleep(200);
136
+ }
137
+ }
138
+
139
+ // The out-of-band backstop, and the path #1647 prefers: it terminates the process
140
+ // without typing anything into the pane, so it is always reached when the guard above
141
+ // declines to authorize a graceful exit.
142
+ (deps.reap ?? reapTmuxSession)(sessionName, socketName);
143
+ }
144
+
145
+ function reapTmuxSession(sessionName: string, socketName?: string): void {
146
+ Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
147
+ stdin: "ignore", stdout: "ignore", stderr: "ignore",
148
+ });
149
+ }