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.
- 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,5 +1,5 @@
|
|
|
1
1
|
import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
|
|
2
|
-
import type { ProviderStatusUpdate } from "../adapter";
|
|
2
|
+
import type { PaneObservation, ProviderStatusUpdate } from "../adapter";
|
|
3
3
|
import type { ClaudeProbeActivity } from "./claude-session-probe";
|
|
4
4
|
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
5
5
|
|
|
@@ -16,12 +16,13 @@ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-l
|
|
|
16
16
|
// readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
|
|
17
17
|
// "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
|
|
18
18
|
// the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
|
|
19
|
-
// busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner
|
|
20
|
-
//
|
|
19
|
+
// busy (claudePaneIsBusy) breaks if CC drops BOTH the live "… (<elapsed>" spinner
|
|
20
|
+
// counter and the 2.1.218 "Thought for <elapsed> (ctrl+o to expand)" activity line
|
|
21
|
+
// (the "esc to interrupt" hint was already dropped in 2.1.x).
|
|
21
22
|
// FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
|
|
22
23
|
// transcript discussing "esc to interrupt", or this very comment shown in a pane)
|
|
23
|
-
// reads as ready
|
|
24
|
-
//
|
|
24
|
+
// reads as ready. Busy checks gate themselves on the LAST N lines (the live
|
|
25
|
+
// footer), including direct callers such as the prompt-gate guard.
|
|
25
26
|
// History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
|
|
26
27
|
export function claudePaneLooksReady(text: string): boolean {
|
|
27
28
|
// Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
|
|
@@ -44,30 +45,117 @@ export function claudePaneLooksReady(text: string): boolean {
|
|
|
44
45
|
// (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
|
|
45
46
|
// "/btw … without interrupting Claude's current work" queue hint.
|
|
46
47
|
const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
|
|
48
|
+
// Claude Code 2.1.218 replaced the gerund spinner during its thinking phase with
|
|
49
|
+
// "Thought for 13s (ctrl+o to expand)" (and, after a tool call starts,
|
|
50
|
+
// "Thought for 12s, called callmux (ctrl+o to expand)"). The intrinsic session
|
|
51
|
+
// probe remains the primary, version-robust activity source, but 2.1.218 can leave
|
|
52
|
+
// that probe idle during this phase. Treat this live footer as a busy veto so a
|
|
53
|
+
// monitorless worker cannot false-idle before its first assistant output.
|
|
54
|
+
const CLAUDE_BUSY_THOUGHT_RE = /^\s*Thought for (?:\d+h\s+)?(?:\d+m\s+)?\d+s(?:,\s*called [^(\n]+)?\s*\(ctrl\+o to expand\)\s*$/im;
|
|
47
55
|
const CLAUDE_ACTIVITY_FOOTER_LINES = 10;
|
|
48
56
|
|
|
49
57
|
export function claudePaneIsBusy(text: string): boolean {
|
|
50
58
|
// Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
|
|
51
|
-
// dropped that hint
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
|
|
59
|
+
// dropped that hint. Keep both older signals and the 2.1.218 thinking line so
|
|
60
|
+
// the pane veto works across those formats. Only inspect the live footer:
|
|
61
|
+
// completed "Thought for …" rows remain in scrollback after a turn.
|
|
62
|
+
const footer = claudeActivityFooter(text);
|
|
63
|
+
return CLAUDE_BUSY_SPINNER_RE.test(footer)
|
|
64
|
+
|| CLAUDE_BUSY_THOUGHT_RE.test(footer)
|
|
65
|
+
|| footer.includes("esc to interrupt");
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
function claudeActivityFooter(text: string): string {
|
|
58
69
|
return text.split("\n").slice(-CLAUDE_ACTIVITY_FOOTER_LINES).join("\n");
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
// #1588 — persistent TUI chrome that is present at the prompt regardless of whether a
|
|
73
|
+
// conversation is loaded, so it must NEVER be picked as a pre-clear conversation anchor.
|
|
74
|
+
// (Includes the claudePaneLooksReady footer markers plus the box/hint chrome around them.)
|
|
75
|
+
const CLAUDE_CHROME_MARKERS = [
|
|
76
|
+
"bypass permissions",
|
|
77
|
+
"shift+tab to cycle",
|
|
78
|
+
"? for shortcuts",
|
|
79
|
+
"/effort",
|
|
80
|
+
"Welcome back",
|
|
81
|
+
"Claude Code",
|
|
82
|
+
"esc to interrupt",
|
|
83
|
+
"ctrl+o to expand",
|
|
84
|
+
"/btw",
|
|
85
|
+
"Tip:",
|
|
86
|
+
"Tips for getting started",
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
// #1588 — pick a distinctive line from the PRE-clear pane that belongs to the loaded
|
|
90
|
+
// conversation body (not the persistent chrome/footer), so its DISAPPEARANCE after `/clear`
|
|
91
|
+
// is positive evidence the conversation was actually wiped. claudePaneLooksReady alone can't
|
|
92
|
+
// tell "before clear" from "after clear" — its footer markers are present in both — so the
|
|
93
|
+
// self-resume barrier (waitForClaudeClearCommitted) keys off this anchor instead. Returns the
|
|
94
|
+
// longest content-rich, chrome-free line, or undefined when the pane has no such line (e.g. an
|
|
95
|
+
// already-empty conversation) — in which case the clear can't be positively confirmed and the
|
|
96
|
+
// caller falls to continuationDelivered:false so the #1023 backstop covers it.
|
|
97
|
+
export function claudeConversationAnchor(pane: string): string | undefined {
|
|
98
|
+
let best: string | undefined;
|
|
99
|
+
for (const raw of pane.split("\n")) {
|
|
100
|
+
const line = raw.trim();
|
|
101
|
+
if (line.length < 20) continue;
|
|
102
|
+
if (CLAUDE_CHROME_MARKERS.some((marker) => line.includes(marker))) continue;
|
|
103
|
+
// Require substantial alphanumeric content so a box-border / ASCII-art / punctuation
|
|
104
|
+
// line can never serve as the anchor (those can reappear on the fresh cleared screen).
|
|
105
|
+
const alnum = (line.match(/[A-Za-z0-9]/g) ?? []).length;
|
|
106
|
+
if (alnum < 12) continue;
|
|
107
|
+
if (!best || line.length > best.length) best = line;
|
|
108
|
+
}
|
|
109
|
+
return best;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// #1588 — CLEAR-AWARE completion check for the self-resume barrier. The `/clear` keystroke is
|
|
113
|
+
// async in the Claude TUI: submitTextToTmux returns as soon as the keys are typed, and the
|
|
114
|
+
// persistent footer (claudePaneLooksReady) matches the STILL-UNCLEARED pane, so accepting the
|
|
115
|
+
// first ready match let the continuation race the async clear and get wiped (the #1588 window).
|
|
116
|
+
// A clear is treated as COMMITTED only with positive evidence: the pane is (a) not busy (the
|
|
117
|
+
// clear/redraw finished processing), (b) back at the input-ready footer, and (c) no longer shows
|
|
118
|
+
// the pre-clear conversation anchor (the loaded conversation is gone). Any of these missing →
|
|
119
|
+
// NOT committed → keep waiting (and, on timeout, the caller reports continuationDelivered:false
|
|
120
|
+
// so the backstop re-injects — uncertainty resolves toward the backstop, never toward a wipe).
|
|
121
|
+
export function claudeClearCommitted(preClearAnchor: string | undefined, currentPane: string): boolean {
|
|
122
|
+
if (claudePaneIsBusy(currentPane)) return false;
|
|
123
|
+
if (!claudePaneLooksReady(currentPane)) return false;
|
|
124
|
+
if (!preClearAnchor) return false;
|
|
125
|
+
return !currentPane.includes(preClearAnchor);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ClaudeActivityReadOptions {
|
|
129
|
+
/** #1621: the probe's busy assertion has not been rewritten inside its trust window
|
|
130
|
+
* (claudeProbeBusyIsStale), so it may describe a turn that was cancelled out of band. */
|
|
131
|
+
probeBusyStale?: boolean;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function claudeActivityFromProbeAndPane(
|
|
135
|
+
probe: ClaudeProbeActivity | undefined,
|
|
136
|
+
pane: string,
|
|
137
|
+
opts: ClaudeActivityReadOptions = {},
|
|
138
|
+
): ClaudeProbeActivity {
|
|
62
139
|
const footer = claudeActivityFooter(pane);
|
|
63
|
-
|
|
64
|
-
if (
|
|
140
|
+
// The pane's activity footer is the strongest busy evidence there is and always wins.
|
|
141
|
+
if (claudePaneIsBusy(footer)) return "busy";
|
|
142
|
+
const ready = claudePaneLooksReady(footer);
|
|
143
|
+
if (probe === "busy") {
|
|
144
|
+
// A FRESH probe-busy stays authoritative: shell→busy is exactly the idle-while-working
|
|
145
|
+
// read the pane-scrape gets wrong (#766). A STALE one cannot be falsified by the probe
|
|
146
|
+
// itself — Claude Code writes the file only on a transition, and an out-of-band cancel
|
|
147
|
+
// makes no transition (#1621) — so the pane must corroborate it. Only a positively
|
|
148
|
+
// input-ready footer (the prompt box, no activity marker) may overrule it; a
|
|
149
|
+
// shelled-out full-screen pane or an unreadable capture keeps the turn busy.
|
|
150
|
+
return opts.probeBusyStale && ready ? "idle" : "busy";
|
|
151
|
+
}
|
|
152
|
+
if (ready) return "idle";
|
|
65
153
|
return probe === "idle" ? "unknown" : probe ?? "unknown";
|
|
66
154
|
}
|
|
67
155
|
|
|
68
156
|
// #286: detect the subscription usage-limit modal in the pane and turn it into the
|
|
69
157
|
// provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
|
|
70
|
-
// at reset.
|
|
158
|
+
// at reset. Unlike the pane model-rejection observation below, it remains a legacy status path.
|
|
71
159
|
export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
|
|
72
160
|
const parsed = parseClaudeRateLimitPane(text);
|
|
73
161
|
if (!parsed) return null;
|
|
@@ -83,78 +171,122 @@ export function claudeRateLimitStatus(text: string, sessionName?: string): Provi
|
|
|
83
171
|
};
|
|
84
172
|
}
|
|
85
173
|
|
|
86
|
-
// #769 — detect the API connection-retry banner in the live terminal footer
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
174
|
+
// #769 — detect the API connection-retry banner in the live terminal footer. Three stable
|
|
175
|
+
// anchors from the banner text: "Unable to connect to API", "Retrying in <N>s",
|
|
176
|
+
// "attempt <N>/<M>". Gated on the LAST N lines so agent output that merely QUOTES these
|
|
177
|
+
// strings (e.g. quoting this comment, or a discussion of retry logic) doesn't false-positive —
|
|
178
|
+
// the live banner always lives in the footer; quoted strings appear in the scrollback body.
|
|
179
|
+
// Break condition: if CC renames/removes any anchor substring the detector stops firing — a
|
|
180
|
+
// fast fix once the new footer wording is known, and one with no correctness consequence now
|
|
181
|
+
// that this is advisory.
|
|
182
|
+
//
|
|
183
|
+
// #1651 — this returns a DIAGNOSTIC, not a status. It used to return
|
|
184
|
+
// `status: "idle"` + `clear: ["subagent"]` + a blocked `conn_retry` providerState. The runner
|
|
185
|
+
// treats an idle provider-turn as the turn ENDING (runner-core setProviderStatus), which
|
|
186
|
+
// clears the work record and calls completeObservedTaskClaims(): retry chrome rendered in a
|
|
187
|
+
// pane could therefore COMPLETE THE TASK CLAIMS OF A TURN THAT WAS STILL RUNNING. The footer
|
|
188
|
+
// gate did not prevent this — the banner is genuinely in the footer during a live turn, which
|
|
189
|
+
// is exactly when the detector fired.
|
|
190
|
+
//
|
|
191
|
+
// The turn stays live/unknown throughout the retries. Claude's own retry ladder is long
|
|
192
|
+
// (measured at 179s for a refused connection), but lateness is not permission to infer failure
|
|
193
|
+
// from pane text: only a correlated StopFailure may end the turn, after the native ladder
|
|
194
|
+
// exhausts. Disappearance likewise proves nothing — it only clears the diagnostic flag.
|
|
94
195
|
const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
|
|
95
196
|
const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
|
|
96
197
|
const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
|
|
97
198
|
const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
|
|
98
199
|
|
|
99
|
-
export function
|
|
200
|
+
export function claudeConnectionRetryObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
|
|
100
201
|
const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
|
|
101
202
|
if (
|
|
102
203
|
!CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
|
|
103
204
|
!CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
|
|
104
205
|
!CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
|
|
105
206
|
) return null;
|
|
106
|
-
const now = Date.now();
|
|
107
207
|
return {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
208
|
+
eventType: "claude.pane_connection_retry_candidate",
|
|
209
|
+
source: "pane",
|
|
210
|
+
detectorId: "connection-retry",
|
|
211
|
+
observedAt: nowMs ?? Date.now(),
|
|
212
|
+
...(sessionName ? { sessionName } : {}),
|
|
213
|
+
message: "Pane shows Claude's API connection-retry banner. Advisory only: the turn is unchanged.",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The falling edge. It used to publish a bare `idle` — an unconditional turn-end with no
|
|
218
|
+
* evidence behind it at all. A banner leaving the footer proves nothing about the turn, so
|
|
219
|
+
* this is a diagnostic too: it clears the caller's flag and says so. */
|
|
220
|
+
export function claudeConnectionRetryClearedObservation(sessionName?: string, nowMs?: number): PaneObservation {
|
|
221
|
+
return {
|
|
222
|
+
eventType: "claude.pane_connection_retry_cleared",
|
|
223
|
+
source: "pane",
|
|
224
|
+
detectorId: "connection-retry",
|
|
225
|
+
observedAt: nowMs ?? Date.now(),
|
|
226
|
+
...(sessionName ? { sessionName } : {}),
|
|
227
|
+
message: "Pane no longer shows the connection-retry banner. Advisory only: proves nothing about the turn.",
|
|
122
228
|
};
|
|
123
229
|
}
|
|
124
230
|
|
|
125
|
-
|
|
126
|
-
|
|
231
|
+
// #1651 — this returns a DIAGNOSTIC, not a status. It used to return `status: "error"` +
|
|
232
|
+
// `clear: ["provider-turn", "subagent"]` + a TERMINAL failed provider state + suppressed
|
|
233
|
+
// restart from a single pane match — reproduced live: the harmless sentence "The test fixture
|
|
234
|
+
// says: not an available model" triggered every one of those, from a pane, where the matched
|
|
235
|
+
// text may simply be an agent quoting a fixture, a code comment, or this very file.
|
|
236
|
+
//
|
|
237
|
+
// A pane match is never terminal. Only a correlated StopFailure whose native error type
|
|
238
|
+
// explicitly denotes model-not-found/unavailable (not yet wired — see #1651's Direction
|
|
239
|
+
// section) may end a turn this way. This detector only notices.
|
|
240
|
+
//
|
|
241
|
+
// Scoped to the trailing modal/composer structural block: the same positional technique
|
|
242
|
+
// rate-limit.ts's livePaneBlock uses (Claude Code pins its composer to the bottom of the
|
|
243
|
+
// screen, separated from the conversation by a blank row, and draws blocking dialogs in that
|
|
244
|
+
// same spot), reimplemented locally rather than importing that module's private helper. An
|
|
245
|
+
// agent's own scrollback quoting/discussing the rejection text sits above the blank row and
|
|
246
|
+
// so can never reach this scan.
|
|
247
|
+
//
|
|
248
|
+
// Repetition is the caller's job, not this function's: claude.ts requires the SAME message on
|
|
249
|
+
// two consecutive polls before it will even publish the diagnostic — this stays pure so it can
|
|
250
|
+
// be unit tested without threading adapter state through it.
|
|
251
|
+
export function claudeModelUnavailableObservation(text: string, sessionName?: string, nowMs?: number): PaneObservation | null {
|
|
252
|
+
const block = trailingStructuralBlock(text);
|
|
253
|
+
const message = extractClaudeModelUnavailableMessage(block);
|
|
127
254
|
if (!message) return null;
|
|
128
255
|
return {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
source: "claude-pane",
|
|
136
|
-
terminal: true,
|
|
137
|
-
...(sessionName ? { sessionName } : {}),
|
|
138
|
-
recommendedAction: "Choose a different Claude model before restarting this agent.",
|
|
139
|
-
},
|
|
140
|
-
metadata: {
|
|
141
|
-
terminalFailureReason: "model-unavailable",
|
|
142
|
-
terminalFailureMessage: message,
|
|
143
|
-
},
|
|
144
|
-
timeline: {
|
|
145
|
-
status: "provider.restart_decision",
|
|
146
|
-
id: `provider-model-unavailable-${Date.now()}`,
|
|
147
|
-
timestamp: Date.now(),
|
|
148
|
-
title: "Provider restart skipped",
|
|
149
|
-
body: message,
|
|
150
|
-
icon: "ti-player-stop",
|
|
151
|
-
metadata: {
|
|
152
|
-
eventType: "provider.restart_decision",
|
|
153
|
-
decision: "stop-surface",
|
|
154
|
-
reason: "model-unavailable",
|
|
155
|
-
modelUnavailable: true,
|
|
156
|
-
modelUnavailableMessage: message,
|
|
157
|
-
},
|
|
158
|
-
},
|
|
256
|
+
eventType: "claude.pane_model_unavailable_candidate",
|
|
257
|
+
source: "pane",
|
|
258
|
+
detectorId: "model-unavailable",
|
|
259
|
+
observedAt: nowMs ?? Date.now(),
|
|
260
|
+
...(sessionName ? { sessionName } : {}),
|
|
261
|
+
message,
|
|
159
262
|
};
|
|
160
263
|
}
|
|
264
|
+
|
|
265
|
+
export interface ClaudeModelUnavailableObservationState {
|
|
266
|
+
message?: string;
|
|
267
|
+
reported: boolean;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function initialClaudeModelUnavailableObservationState(): ClaudeModelUnavailableObservationState {
|
|
271
|
+
return { reported: false };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Require two identical reads before a pane candidate reaches diagnostic telemetry. */
|
|
275
|
+
export function debounceClaudeModelUnavailableObservation(
|
|
276
|
+
observation: PaneObservation | null,
|
|
277
|
+
state: ClaudeModelUnavailableObservationState,
|
|
278
|
+
): { state: ClaudeModelUnavailableObservationState; observation?: PaneObservation } {
|
|
279
|
+
if (!observation?.message) return { state: initialClaudeModelUnavailableObservationState() };
|
|
280
|
+
if (state.message !== observation.message) return { state: { message: observation.message, reported: false } };
|
|
281
|
+
if (state.reported) return { state };
|
|
282
|
+
return { state: { ...state, reported: true }, observation };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function trailingStructuralBlock(text: string): string {
|
|
286
|
+
const lines = text.split(/\r?\n/);
|
|
287
|
+
let end = lines.length;
|
|
288
|
+
while (end > 0 && lines[end - 1]!.trim() === "") end--;
|
|
289
|
+
let start = end;
|
|
290
|
+
while (start > 0 && lines[start - 1]!.trim() !== "") start--;
|
|
291
|
+
return lines.slice(start, end).join("\n");
|
|
292
|
+
}
|
|
@@ -9,8 +9,9 @@ import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
9
9
|
import { dirname, join } from "node:path";
|
|
10
10
|
import { sanitizeFsName } from "agent-relay-sdk/fs-name";
|
|
11
11
|
import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
|
|
12
|
-
import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
13
|
-
import { claudePaneLooksReady } from "./claude-status-detectors";
|
|
12
|
+
import { TMUX_SESSION_OWNER_OPTION, tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
|
|
13
|
+
import { claudeClearCommitted, claudeConversationAnchor, claudePaneLooksReady } from "./claude-status-detectors";
|
|
14
|
+
import { tmuxOwnerIdFromEnv } from "../config";
|
|
14
15
|
import { launcherGatePathForSession, launcherScriptPathForSession } from "../session-scratch";
|
|
15
16
|
|
|
16
17
|
export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
|
|
@@ -24,6 +25,13 @@ export function tmuxSessionName(prefix: string, instanceId: string, label?: stri
|
|
|
24
25
|
return `${prefix}-${instanceId.slice(0, 8)}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// tmux silently rewrites `.` and `:` in new session names to `_` (a colon also
|
|
29
|
+
// separates the session/window/pane portions of a target). All later `-t` lookups
|
|
30
|
+
// must use the rendered name or `has-session` misses the session tmux just created.
|
|
31
|
+
export function tmuxSessionTargetName(sessionName: string): string {
|
|
32
|
+
return sessionName.replace(/[.:]/g, "_");
|
|
33
|
+
}
|
|
34
|
+
|
|
27
35
|
export function tmuxSocketName(sessionName: string): string {
|
|
28
36
|
// `tmux -L` puts this value at <TMUX_TMPDIR>/tmux-<uid>/<socket>. Unix
|
|
29
37
|
// socket paths are limited to roughly 104-108 bytes, so a display-oriented
|
|
@@ -44,13 +52,14 @@ export function legacyTmuxSocketName(sessionName: string): string {
|
|
|
44
52
|
}
|
|
45
53
|
|
|
46
54
|
export function createOrReuseTmuxSession(sessionName: string, socketName: string, tmuxArgs: string[]): void {
|
|
47
|
-
|
|
55
|
+
const targetSessionName = tmuxSessionTargetName(sessionName);
|
|
56
|
+
Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", targetSessionName), {
|
|
48
57
|
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
49
58
|
});
|
|
50
59
|
// Upgrade cleanup: a pre-#1353 runner used the readable session name as its
|
|
51
60
|
// selector. Best-effort removal prevents a legacy pane surviving beside its
|
|
52
61
|
// replacement; an overlong legacy selector can fail here harmlessly.
|
|
53
|
-
Bun.spawnSync(tmuxCommand(legacyTmuxSocketName(sessionName), "kill-session", "-t",
|
|
62
|
+
Bun.spawnSync(tmuxCommand(legacyTmuxSocketName(sessionName), "kill-session", "-t", targetSessionName), {
|
|
54
63
|
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
55
64
|
});
|
|
56
65
|
const result = Bun.spawnSync(tmuxCommand(socketName, ...tmuxArgs), {
|
|
@@ -59,10 +68,37 @@ export function createOrReuseTmuxSession(sessionName: string, socketName: string
|
|
|
59
68
|
const stderr = result.stderr.toString().trim();
|
|
60
69
|
// A restart can race a still-live predecessor. Reuse the uniquely named,
|
|
61
70
|
// confirmed session rather than failing the healthy agent on "duplicate".
|
|
62
|
-
const reusable =
|
|
71
|
+
const reusable = tmuxSessionCreationFailureIsReusable(stderr, targetSessionName, socketName);
|
|
63
72
|
if (result.exitCode !== 0 && !reusable) {
|
|
64
73
|
throw new Error(`tmux session creation failed: ${stderr || `exit code ${result.exitCode}`}`);
|
|
65
74
|
}
|
|
75
|
+
// #1514 — stamp the owning orchestrator's state-home id (passed to this runner via
|
|
76
|
+
// env) onto the session so the orchestrator's wedged-session reaper reaps ONLY the
|
|
77
|
+
// sessions it positively owns, never a co-tenant orchestrator's on the same host.
|
|
78
|
+
setTmuxSessionOwner(targetSessionName, tmuxOwnerIdFromEnv(), socketName);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function tmuxSessionCreationFailureIsReusable(
|
|
82
|
+
stderr: string,
|
|
83
|
+
sessionName: string,
|
|
84
|
+
socketName: string,
|
|
85
|
+
hasSession: (session: string, socket?: string) => boolean = tmuxHasSession,
|
|
86
|
+
): boolean {
|
|
87
|
+
return /duplicate session/i.test(stderr)
|
|
88
|
+
&& hasSession(tmuxSessionTargetName(sessionName), socketName);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// #1514 — stamp the owning orchestrator's state-home id onto the tmux session as a
|
|
92
|
+
// user-option. The orchestrator's wedged-session reaper reads it back and reaps ONLY
|
|
93
|
+
// sessions whose owner id matches its own, so it can never reap a session belonging
|
|
94
|
+
// to a different orchestrator that happens to share the UID + tmux prefix on this
|
|
95
|
+
// host. Best-effort: a failure just leaves the session untagged, which the reaper
|
|
96
|
+
// treats as "not positively owned" and skips (fail-safe).
|
|
97
|
+
export function setTmuxSessionOwner(sessionName: string, ownerId: string | undefined, socketName?: string): void {
|
|
98
|
+
if (!ownerId) return;
|
|
99
|
+
Bun.spawnSync(tmuxCommand(socketName, "set-option", "-t", sessionName, TMUX_SESSION_OWNER_OPTION, ownerId), {
|
|
100
|
+
stdin: "ignore", stdout: "ignore", stderr: "ignore",
|
|
101
|
+
});
|
|
66
102
|
}
|
|
67
103
|
|
|
68
104
|
export function tmuxSocketPath(socketName: string, socketDir = defaultTmuxSocketDir()): string {
|
|
@@ -107,6 +143,14 @@ export function captureTmuxPane(sessionName: string, socketName?: string): strin
|
|
|
107
143
|
return result.stdout.toString();
|
|
108
144
|
}
|
|
109
145
|
|
|
146
|
+
// #1514's exit-dialog confirmation used to live here as
|
|
147
|
+
// `confirmClaudeExitDialogIfPresent(sessionName, socketName)` — an Enter writer that
|
|
148
|
+
// any caller holding a session name could invoke. #1647 removed it: taking a session
|
|
149
|
+
// name meant nothing in the type system stopped a pane detector from calling it, and
|
|
150
|
+
// the production kill was exactly that Enter answering an "Exit anyway" modal on a
|
|
151
|
+
// live pane. It now lives in ./claude-exit, where both it and the `/exit` writer take
|
|
152
|
+
// an unforgeable one-shot ClaudeExitGrant instead.
|
|
153
|
+
|
|
110
154
|
export async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
111
155
|
const deadline = Date.now() + timeoutMs;
|
|
112
156
|
while (Date.now() < deadline) {
|
|
@@ -117,6 +161,51 @@ export async function waitForClaudeInputReady(sessionName: string, timeoutMs = C
|
|
|
117
161
|
throw new Error(`Claude input was not ready within ${timeoutMs}ms`);
|
|
118
162
|
}
|
|
119
163
|
|
|
164
|
+
export const CLAUDE_CLEAR_COMMIT_POLL_MS = 200;
|
|
165
|
+
|
|
166
|
+
// Injectable seams so the polling loop (which otherwise only talks to a live tmux server) is
|
|
167
|
+
// unit-testable — same pattern as confirmTmuxSessionGone's `hasSession` override.
|
|
168
|
+
export interface ClaudeClearCommitDeps {
|
|
169
|
+
capture?: (sessionName: string, socketName?: string) => string;
|
|
170
|
+
hasSession?: (sessionName: string, socketName?: string) => boolean;
|
|
171
|
+
sleep?: (ms: number) => Promise<unknown>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// #1588 — CLEAR-AWARE settle barrier for the self-resume clear+inject coupling. Unlike
|
|
175
|
+
// waitForClaudeInputReady (which returns on the first persistent-footer match — present BOTH
|
|
176
|
+
// before and after `/clear`, the F2 race), this requires POSITIVE evidence the `/clear` actually
|
|
177
|
+
// committed: it snapshots a distinctive pre-clear conversation anchor and only reports success
|
|
178
|
+
// once the pane is idle, input-ready, AND that anchor is gone (the loaded conversation was wiped).
|
|
179
|
+
// Returns a BOOLEAN rather than throwing: `true` = clear positively confirmed committed; `false` =
|
|
180
|
+
// timed out / session vanished / capture failed / no anchor derivable (uncertain). The caller MUST
|
|
181
|
+
// treat `false` as "not confirmed" and report continuationDelivered:false so the #1023 backstop
|
|
182
|
+
// re-injects — never as a wipe. A settle timeout is therefore surfaced, not swallowed.
|
|
183
|
+
export async function waitForClaudeClearCommitted(
|
|
184
|
+
sessionName: string,
|
|
185
|
+
preClearPane: string,
|
|
186
|
+
timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS,
|
|
187
|
+
socketName?: string,
|
|
188
|
+
deps: ClaudeClearCommitDeps = {},
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
const capture = deps.capture ?? captureTmuxPane;
|
|
191
|
+
const hasSession = deps.hasSession ?? tmuxHasSession;
|
|
192
|
+
const sleep = deps.sleep ?? Bun.sleep;
|
|
193
|
+
const anchor = claudeConversationAnchor(preClearPane);
|
|
194
|
+
const deadline = Date.now() + timeoutMs;
|
|
195
|
+
while (Date.now() < deadline) {
|
|
196
|
+
if (!hasSession(sessionName, socketName)) return false;
|
|
197
|
+
let pane: string;
|
|
198
|
+
try {
|
|
199
|
+
pane = capture(sessionName, socketName);
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
if (claudeClearCommitted(anchor, pane)) return true;
|
|
204
|
+
await sleep(CLAUDE_CLEAR_COMMIT_POLL_MS);
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
120
209
|
function pasteTextIntoTmux(sessionName: string, text: string, socketName?: string): void {
|
|
121
210
|
const bufferName = `agent-relay-${crypto.randomUUID()}`;
|
|
122
211
|
const setResult = Bun.spawnSync(tmuxCommand(socketName, "set-buffer", "-b", bufferName, text), {
|
|
@@ -14,6 +14,11 @@ interface TailFingerprint {
|
|
|
14
14
|
bytes: Buffer;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
interface TranscriptTailFs {
|
|
18
|
+
stat: typeof stat;
|
|
19
|
+
open: typeof open;
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
export interface IncrementalTranscriptPollResult {
|
|
18
23
|
available: boolean;
|
|
19
24
|
changed: boolean;
|
|
@@ -29,10 +34,12 @@ export class IncrementalClaudeTranscriptTail {
|
|
|
29
34
|
private fingerprint: TailFingerprint | undefined;
|
|
30
35
|
private readonly accumulator = new LatestTurnStepAccumulator();
|
|
31
36
|
|
|
37
|
+
constructor(private readonly fs: TranscriptTailFs = { stat, open }) {}
|
|
38
|
+
|
|
32
39
|
async poll(path: string): Promise<IncrementalTranscriptPollResult> {
|
|
33
40
|
let fileStat: Awaited<ReturnType<typeof stat>>;
|
|
34
41
|
try {
|
|
35
|
-
fileStat = await stat(path);
|
|
42
|
+
fileStat = await this.fs.stat(path);
|
|
36
43
|
} catch {
|
|
37
44
|
return this.result({ available: false, changed: false, reset: false });
|
|
38
45
|
}
|
|
@@ -54,7 +61,14 @@ export class IncrementalClaudeTranscriptTail {
|
|
|
54
61
|
const length = fileStat.size - this.offset;
|
|
55
62
|
const buffer = Buffer.alloc(length);
|
|
56
63
|
let bytesRead = 0;
|
|
57
|
-
|
|
64
|
+
// A transcript can be rotated after stat() but before open(). That is ordinary lifecycle
|
|
65
|
+
// churn, not a runner-fatal error: report this poll as unavailable and retry next interval.
|
|
66
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
67
|
+
try {
|
|
68
|
+
handle = await this.fs.open(path, "r");
|
|
69
|
+
} catch {
|
|
70
|
+
return this.result({ available: false, changed: false, reset });
|
|
71
|
+
}
|
|
58
72
|
try {
|
|
59
73
|
const read = await handle.read(buffer, 0, length, this.offset);
|
|
60
74
|
bytesRead = read.bytesRead;
|
|
@@ -80,7 +94,12 @@ export class IncrementalClaudeTranscriptTail {
|
|
|
80
94
|
private async fingerprintMatches(path: string): Promise<boolean> {
|
|
81
95
|
if (!this.fingerprint) return true;
|
|
82
96
|
const buffer = Buffer.alloc(this.fingerprint.bytes.length);
|
|
83
|
-
|
|
97
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
98
|
+
try {
|
|
99
|
+
handle = await this.fs.open(path, "r");
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
84
103
|
try {
|
|
85
104
|
const read = await handle.read(buffer, 0, buffer.length, this.fingerprint.offset);
|
|
86
105
|
return read.bytesRead === buffer.length && buffer.equals(this.fingerprint.bytes);
|