agent-relay-runner 0.129.11 → 0.129.12
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 +3 -3
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +8 -8
- package/src/adapter.ts +113 -11
- package/src/adapters/claude-delivery.ts +1 -7
- package/src/adapters/claude-exit.ts +289 -0
- package/src/adapters/claude-prompt-gates.ts +59 -79
- package/src/adapters/claude-session-capture.ts +13 -2
- package/src/adapters/claude-session-probe.ts +98 -7
- package/src/adapters/claude-shutdown.ts +149 -0
- package/src/adapters/claude-status-detectors.ts +201 -69
- package/src/adapters/claude-tmux.ts +94 -5
- package/src/adapters/claude-transcript-tail.ts +22 -3
- package/src/adapters/claude-transcript.ts +95 -1
- package/src/adapters/claude.ts +179 -76
- package/src/adapters/codex.ts +46 -8
- package/src/busy-reconciler.ts +67 -29
- package/src/config.ts +16 -0
- package/src/launch-assembly.ts +251 -82
- package/src/native-memory-lever.ts +308 -0
- package/src/outbox.ts +22 -4
- package/src/profile-projection.ts +45 -1
- package/src/provider-input-queue.ts +100 -0
- package/src/rate-limit.ts +106 -36
- package/src/relay-injection-events.ts +24 -0
- package/src/relay-instructions.ts +9 -0
- package/src/relay-mcp-proxy.ts +79 -8
- package/src/relay-mcp.ts +217 -8
- package/src/runner-core.ts +323 -47
- package/src/runner-helpers.ts +10 -0
- package/src/transcript-context.ts +83 -0
- package/src/turn-reconcile-event.ts +48 -0
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
-
import { isRecord
|
|
3
|
-
import type {
|
|
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
|
|
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
|
244
|
+
function firstDetectedPaneGate(text: string): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
|
|
236
245
|
for (const entry of panePromptGates) {
|
|
237
|
-
const match = entry.match(text
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|