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,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 counter
20
- // (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
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/busy. The activity reconciler gates on the LAST N lines only (the
24
- // live footer); direct raw predicates are still used by readiness/wait gates.
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 but kept the "(<elapsed>" counter, which is the stable busy
52
- // signal across versions. Match either so the busy probe (and the reconciler
53
- // backstop that depends on it) keep working as the footer wording changes.
54
- return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
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
- export function claudeActivityFromProbeAndPane(probe: ClaudeProbeActivity | undefined, pane: string): ClaudeProbeActivity {
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
- if (probe === "busy" || claudePaneIsBusy(footer)) return "busy";
64
- if (claudePaneLooksReady(footer)) return "idle";
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. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
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 and surface a
87
- // transient blocked hold (non-terminal, clears on recovery). Three stable anchors from the
88
- // banner text: "Unable to connect to API", "Retrying in <N>s", "attempt <N>/<M>".
89
- // Gated on the LAST N lines so agent output that merely QUOTES these strings (e.g. quoting
90
- // this comment, or a discussion of retry logic) doesn't false-positive the live banner
91
- // always lives in the footer; quoted strings appear in the scrollback body.
92
- // Break condition: if CC renames/removes any anchor substring the detector stops firing and
93
- // sessions return to idle/available — a fast fix once the new footer wording is known.
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 claudeConnectionRetryStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
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
- status: "idle",
109
- clear: ["subagent"],
110
- providerState: {
111
- state: "blocked",
112
- reason: "conn_retry",
113
- kind: "conn_retry",
114
- label: "connection degraded · retrying",
115
- message: "Claude cannot reach the Anthropic API and is retrying the connection.",
116
- source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
117
- terminal: false,
118
- recommendedAction: "Holding; agent-relay will clear this state once the session reconnects.",
119
- enteredAt: now,
120
- updatedAt: now,
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
- export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
126
- const message = extractClaudeModelUnavailableMessage(text);
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
- status: "error",
130
- clear: ["provider-turn", "subagent"],
131
- providerState: {
132
- state: "failed",
133
- reason: "model-unavailable",
134
- message,
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
- Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
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", sessionName), {
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 = /duplicate session/i.test(stderr) && tmuxHasSession(sessionName, socketName);
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
- const handle = await open(path, "r");
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
- const handle = await open(path, "r");
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);