agent-relay-runner 0.101.1 → 0.102.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.101.1",
3
+ "version": "0.102.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.101.1",
4
+ "version": "0.102.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -174,6 +174,11 @@ export interface ProviderAdapter {
174
174
  // turn was interrupted from the web terminal so no Stop hook fired). "unknown"
175
175
  // means the provider can't be cheaply probed and the reconciler should defer.
176
176
  probeActivity?(process: ManagedProcess): Promise<"busy" | "idle" | "unknown">;
177
+ // #769 P1-A: an independent "is the provider still visibly busy?" read, consulted
178
+ // ONLY as a veto at the reconciler's force-clear moment — a probeActivity()=idle
179
+ // must not clear a live turn while this returns true (defense-in-depth when the
180
+ // primary probe can momentarily read idle mid-turn, e.g. a connection retry).
181
+ probePaneBusy?(process: ManagedProcess): Promise<boolean>;
177
182
  terminalAttachSpec?(process: ManagedProcess): Promise<TerminalAttachSpec>;
178
183
  respondToPermissionDecision?(process: ManagedProcess, input: ProviderPermissionDecisionInput): Promise<Record<string, unknown> | void>;
179
184
  // `options.readyTimeoutMs` lets the runner widen the provider-ready wait for the
@@ -0,0 +1,225 @@
1
+ // #766/#768 — Claude Code's intrinsic, machine-readable session probe.
2
+ //
3
+ // Claude Code ≥2.1.119 writes `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` and rewrites
4
+ // it on every status transition. It carries an authoritative `status` field
5
+ // (busy|shell|idle|waiting, plus `waitingFor` when waiting) — the very
6
+ // "machine-readable ready/busy signal" the old pane-scrape comment claimed didn't
7
+ // exist. The runner manages the claude pid and its CLAUDE_CONFIG_DIR, so this is a
8
+ // direct keyed read — no enumeration, no cwd/solo-multi disambiguation.
9
+ //
10
+ // This is the single home (DRY) for reading + version-gating + mapping that probe.
11
+ // `probeActivity`, the BusyReconciler backstop, and the monitorless turn-watch all
12
+ // delegate here; the legacy pane-scrape stays only as the graceful fallback for
13
+ // pre-2.1.119 / probe-missing sessions (e.g. a claude-rig host whose real config
14
+ // home the runner can't resolve). Schema is reverse-engineered (pbauermeister/
15
+ // claude-busy-monitor), so we gate on `version`/presence and degrade gracefully if
16
+ // Anthropic ever drifts it.
17
+ import { readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { isPidAlive } from "agent-relay-sdk/process-utils";
21
+ import { tmuxCommand } from "agent-relay-sdk/tmux-utils";
22
+
23
+ /** Raw probe status; "unknown" = absent / unparseable / version-gated out. */
24
+ export type ClaudeProbeStatus = "busy" | "shell" | "idle" | "waiting" | "unknown";
25
+
26
+ /** The 3-valued activity the reconciler + monitorless turn-watch consume. */
27
+ export type ClaudeProbeActivity = "busy" | "idle" | "unknown";
28
+
29
+ /** First Claude Code version that writes the session probe. Below this → fallback. */
30
+ export const CLAUDE_PROBE_MIN_VERSION = "2.1.119";
31
+
32
+ export interface ClaudeSessionReading {
33
+ /** Raw probe status, or "unknown" when the probe is absent/old/unparseable. */
34
+ status: ClaudeProbeStatus;
35
+ /** `waitingFor` reason when status==="waiting" (e.g. "approve Bash"), else null. */
36
+ waitingFor: string | null;
37
+ /** Probe `version`, or null when unavailable. */
38
+ version: string | null;
39
+ /** `procStart` (process start-time token, == `/proc/<pid>/stat` field 22), or null. */
40
+ procStart: string | null;
41
+ /** `updatedAt` epoch-ms of the last probe write, or null. */
42
+ updatedAt: number | null;
43
+ /** True iff a present, parseable, version-gated probe produced `status`. */
44
+ available: boolean;
45
+ }
46
+
47
+ const UNAVAILABLE: ClaudeSessionReading = { status: "unknown", waitingFor: null, version: null, procStart: null, updatedAt: null, available: false };
48
+
49
+ /**
50
+ * Process-liveness seam for the staleness guard (#769 P1-B), injectable in tests.
51
+ * procStartOf — the start-time token of a LIVE pid (to compare against the probe's
52
+ * `procStart`), or null when the pid is dead/unreadable. Catches both a frozen
53
+ * probe from a SIGKILLed claude AND a sub-second pid-reuse window (a reused pid
54
+ * has a different start time).
55
+ * pidAlive — liveness fallback for probes that predate `procStart`.
56
+ */
57
+ export interface ClaudeProbeLiveness {
58
+ procStartOf(pid: number): string | null;
59
+ pidAlive(pid: number): boolean;
60
+ }
61
+
62
+ // Default (Linux /proc) liveness. `procStart` in the probe is `/proc/<pid>/stat`
63
+ // field 22 (starttime, clock-ticks since boot) — verified equal live on CC 2.1.193.
64
+ // NB: we deliberately do NOT gate on `updatedAt` freshness — Claude only rewrites
65
+ // the probe on a status *transition*, so a legitimately busy turn keeps a frozen
66
+ // `updatedAt` for its whole duration (verified: a 90s+ busy turn never rewrote it).
67
+ // A staleness threshold would force a long busy turn back to the pane-scrape and
68
+ // reintroduce the #766 idle-while-working bug; pid-liveness + procStart cover the
69
+ // SIGKILL-frozen and pid-reuse cases without that hazard.
70
+ const DEFAULT_LIVENESS: ClaudeProbeLiveness = {
71
+ procStartOf: readProcStartTime,
72
+ pidAlive: isPidAlive,
73
+ };
74
+
75
+ function readProcStartTime(pid: number): string | null {
76
+ try {
77
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
78
+ // comm (field 2) is parenthesized and may contain spaces — slice past the last
79
+ // ')' so the remaining whitespace split starts cleanly at field 3 (state).
80
+ const after = stat.slice(stat.lastIndexOf(")") + 1).trim();
81
+ const fields = after.split(/\s+/);
82
+ return fields[19] ?? null; // field 22 (starttime) = index 19 after dropping pid+comm
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /** The per-session probe path the runner reads directly (keyed by claude's pid). */
89
+ export function claudeSessionProbePath(configHome: string, pid: number): string {
90
+ return join(configHome, "sessions", `${pid}.json`);
91
+ }
92
+
93
+ /** Dotted-version compare; true iff `version` >= `min`. Missing/non-numeric → gate closed. */
94
+ export function claudeVersionAtLeast(version: string | null | undefined, min: string): boolean {
95
+ if (!version) return false;
96
+ const actual = version.split(".");
97
+ const wanted = min.split(".");
98
+ for (let i = 0; i < wanted.length; i++) {
99
+ const x = Number.parseInt(actual[i] ?? "0", 10);
100
+ const y = Number.parseInt(wanted[i] ?? "0", 10);
101
+ if (Number.isNaN(x)) return false; // unparseable build → fall back, don't trust the probe
102
+ if (x !== y) return x > y;
103
+ }
104
+ return true;
105
+ }
106
+
107
+ function normalizeStatus(value: unknown): ClaudeProbeStatus {
108
+ switch (value) {
109
+ case "busy":
110
+ case "shell":
111
+ case "idle":
112
+ case "waiting":
113
+ return value;
114
+ default:
115
+ return "unknown";
116
+ }
117
+ }
118
+
119
+ /** Pure: parse a probe JSON body + version-gate it. Malformed / old / unknown status → unavailable. */
120
+ export function parseClaudeSessionProbe(raw: string, minVersion = CLAUDE_PROBE_MIN_VERSION): ClaudeSessionReading {
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(raw);
124
+ } catch {
125
+ return UNAVAILABLE;
126
+ }
127
+ if (!parsed || typeof parsed !== "object") return UNAVAILABLE;
128
+ const probe = parsed as Record<string, unknown>;
129
+ const version = typeof probe.version === "string" ? probe.version : null;
130
+ const procStart = typeof probe.procStart === "string" ? probe.procStart : null;
131
+ const updatedAt = typeof probe.updatedAt === "number" ? probe.updatedAt : null;
132
+ if (!claudeVersionAtLeast(version, minVersion)) return { ...UNAVAILABLE, version, procStart, updatedAt };
133
+ const status = normalizeStatus(probe.status);
134
+ if (status === "unknown") return { status: "unknown", waitingFor: null, version, procStart, updatedAt, available: false };
135
+ const waitingFor = typeof probe.waitingFor === "string" && probe.waitingFor.trim() ? probe.waitingFor : null;
136
+ return { status, waitingFor, version, procStart, updatedAt, available: true };
137
+ }
138
+
139
+ /**
140
+ * Read + parse the probe for `pid` under `configHome`. Missing file (older CC, a
141
+ * config home the runner can't resolve, or claude not yet started) → unavailable,
142
+ * so the caller falls back to the pane-scrape. Sync: the file is a few hundred
143
+ * bytes and both call sites (4s reconcile poll, 1.5s monitorless tick) are timers.
144
+ */
145
+ export function readClaudeSessionStatus(
146
+ configHome: string,
147
+ pid: number,
148
+ opts: { minVersion?: string; liveness?: ClaudeProbeLiveness } = {},
149
+ ): ClaudeSessionReading {
150
+ let raw: string;
151
+ try {
152
+ raw = readFileSync(claudeSessionProbePath(configHome, pid), "utf8");
153
+ } catch {
154
+ return UNAVAILABLE;
155
+ }
156
+ const reading = parseClaudeSessionProbe(raw, opts.minVersion ?? CLAUDE_PROBE_MIN_VERSION);
157
+ if (!reading.available) return reading;
158
+ // #769 P1-B: a frozen probe from a SIGKILLed claude (or a reused pid) would report
159
+ // a stale busy/shell forever. Verify the pid still belongs to the same process.
160
+ const live = opts.liveness ?? DEFAULT_LIVENESS;
161
+ if (reading.procStart) {
162
+ if (live.procStartOf(pid) !== reading.procStart) return { ...reading, status: "unknown", available: false };
163
+ } else if (!live.pidAlive(pid)) {
164
+ return { ...reading, status: "unknown", available: false };
165
+ }
166
+ return reading;
167
+ }
168
+
169
+ /**
170
+ * Resolve the claude pid for the session probe (#766/#768): a directly-spawned
171
+ * process exposes it; a tmux-managed (headless) claude is the pane's process —
172
+ * buildTmuxArgs runs `bash launcher.sh` whose launcher `exec`s claude, so the pane
173
+ * pid IS the claude pid (verified live: pane_pid → sessions/<pid>.json). undefined
174
+ * when neither is available, which routes the caller to the pane-scrape fallback.
175
+ */
176
+ export function resolveClaudePid(directPid: number | undefined, tmuxSession?: string, tmuxSocket?: string): number | undefined {
177
+ if (typeof directPid === "number" && directPid > 0) return directPid;
178
+ if (!tmuxSession) return undefined;
179
+ const result = Bun.spawnSync(tmuxCommand(tmuxSocket, "display-message", "-p", "-t", tmuxSession, "-F", "#{pane_pid}"), {
180
+ stdin: "ignore", stdout: "pipe", stderr: "ignore",
181
+ });
182
+ if (result.exitCode !== 0) return undefined;
183
+ const pid = Number.parseInt(result.stdout.toString().trim(), 10);
184
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined;
185
+ }
186
+
187
+ /**
188
+ * Resolve the managed claude's config home + pid, then read the probe. The runner
189
+ * knows the config home (CLAUDE_CONFIG_DIR in the spawn env, or the host default)
190
+ * and the pid (direct or via the tmux pane). Returns the reading plus the resolved
191
+ * pid (for #650 liveness wiring), or undefined when the pid can't be resolved.
192
+ */
193
+ export function readManagedClaudeStatus(args: {
194
+ pid?: number;
195
+ tmuxSession?: string;
196
+ tmuxSocket?: string;
197
+ configHomeEnv?: string;
198
+ }): { reading: ClaudeSessionReading; pid: number } | undefined {
199
+ const pid = resolveClaudePid(args.pid, args.tmuxSession, args.tmuxSocket);
200
+ if (pid === undefined) return undefined;
201
+ const configHome = args.configHomeEnv || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
202
+ return { reading: readClaudeSessionStatus(configHome, pid), pid };
203
+ }
204
+
205
+ /**
206
+ * Map the intrinsic probe status to the 3-valued activity. The whole point of #766:
207
+ * busy/shell → busy — shell is shelled-out/local-bash (a tool/gate is running);
208
+ * the old pane-scrape misread it as idle and force-cleared.
209
+ * idle → idle
210
+ * waiting → unknown — a permission/input dialog is a parked (not done) turn, so
211
+ * we neither assert compute-busy nor let the backstop clear
212
+ * the live turn. `waitingFor` is surfaced separately for
213
+ * permission/asking detection.
214
+ */
215
+ export function claudeProbeActivity(status: ClaudeProbeStatus): ClaudeProbeActivity {
216
+ switch (status) {
217
+ case "busy":
218
+ case "shell":
219
+ return "busy";
220
+ case "idle":
221
+ return "idle";
222
+ default:
223
+ return "unknown";
224
+ }
225
+ }
@@ -14,6 +14,7 @@ import type { SessionEvent } from "../session-insights";
14
14
  import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
15
15
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
16
16
  import { claudeProviderMessageText } from "./claude-delivery";
17
+ import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
17
18
  import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
18
19
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
19
20
 
@@ -130,6 +131,22 @@ export class ClaudeAdapter implements ProviderAdapter {
130
131
  const session = process.meta?.tmuxSession as string | undefined;
131
132
  const socket = process.meta?.tmuxSocket as string | undefined;
132
133
  if (!session || !tmuxHasSession(session, socket)) return "unknown";
134
+ return this.activityFromProbeOrPane(process, session, socket);
135
+ }
136
+
137
+ // Shared probe-primary / pane-fallback activity read (#766/#768), used by both the
138
+ // reconciler-facing probeActivity and the monitorless turn-watch. Primary: Claude
139
+ // Code's intrinsic session probe (busy/shell→busy — the idle-while-working gap the
140
+ // pane-scrape misread; idle→idle; waiting→unknown, a parked turn the backstop must
141
+ // not force-clear). Legacy fallback: scrape the tmux pane (pre-2.1.119 / probe missing).
142
+ private activityFromProbeOrPane(process: ManagedProcess, session: string, socket?: string): ClaudeProbeActivity {
143
+ const managed = readManagedClaudeStatus({
144
+ pid: process.pid,
145
+ tmuxSession: session,
146
+ tmuxSocket: socket,
147
+ configHomeEnv: (process.meta?.env as Record<string, string | undefined> | undefined)?.CLAUDE_CONFIG_DIR,
148
+ });
149
+ if (managed?.reading.available) return claudeProbeActivity(managed.reading.status);
133
150
  let pane: string;
134
151
  try { pane = captureTmuxPane(session, socket); } catch { return "unknown"; }
135
152
  if (claudePaneIsBusy(pane)) return "busy";
@@ -137,6 +154,23 @@ export class ClaudeAdapter implements ProviderAdapter {
137
154
  return "unknown";
138
155
  }
139
156
 
157
+ // #769 P1-A: an independent pane-busy read, consumed ONLY as a veto on the busy
158
+ // backstop's force-clear (BusyReconciler) — a probe-`idle` must never clear a live
159
+ // turn while the pane spinner still reads busy (a connection-retry/529 can write
160
+ // status:idle mid-turn without tripping the rate-limit pane detector — the #769
161
+ // gap). probeActivity stays probe-primary; this is a clear-time veto-toward-busy
162
+ // only. false on no session / capture failure (no veto).
163
+ async probePaneBusy(process: ManagedProcess): Promise<boolean> {
164
+ const session = process.meta?.tmuxSession as string | undefined;
165
+ const socket = process.meta?.tmuxSocket as string | undefined;
166
+ if (!session || !tmuxHasSession(session, socket)) return false;
167
+ try {
168
+ return claudePaneIsBusy(captureTmuxPane(session, socket));
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
140
174
  async deliver(process: ManagedProcess, messages: Message[]): Promise<void> {
141
175
  const monitor = process.meta?.monitor as { deliver?(messages: Message[]): Promise<number[]> } | undefined;
142
176
  // A monitor object always exists for headless claude (it proxies to the runner
@@ -171,15 +205,20 @@ export class ClaudeAdapter implements ProviderAdapter {
171
205
  // status does) — the task lingers until the runtime budget cancels it. Scrape
172
206
  // the tmux pane for the busy→idle transition to synthesize a provider-turn
173
207
  // signal so isolated automation agents reach task "done" like Codex.
174
- if (monitorless) this.beginMonitorlessTurnWatch(session, socket);
208
+ if (monitorless) this.beginMonitorlessTurnWatch(process, session, socket);
175
209
  }
176
210
 
177
- private beginMonitorlessTurnWatch(sessionName: string, socketName?: string): void {
211
+ private beginMonitorlessTurnWatch(process: ManagedProcess, sessionName: string, socketName?: string): void {
178
212
  this.stopTurnWatch();
213
+ // #769 P2-C: feed the resolved claude pid into the provider-turn liveness pids
214
+ // so #650's pid-liveness reconcile arms for headless/monitorless turns too (the
215
+ // process itself isn't tracked here — pid is undefined for tmux-managed claude).
216
+ // A dead pid means the turn is definitively over, so this can't falsely clear.
217
+ const livenessPid = resolveClaudePid(process.pid, sessionName, socketName);
179
218
  // We just submitted a prompt, so a turn is starting. Emit a synthetic
180
219
  // provider-turn busy now so the runner arms task-claim completion; the claim
181
220
  // for this delivery is already registered before deliver() runs.
182
- this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
221
+ this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID, ...(livenessPid ? { pids: [livenessPid] } : {}) });
183
222
  let ticks = 0;
184
223
  let idleStreak = 0;
185
224
  let sawBusy = false;
@@ -191,18 +230,16 @@ export class ClaudeAdapter implements ProviderAdapter {
191
230
  this.stopTurnWatch();
192
231
  return;
193
232
  }
194
- let pane: string;
195
- try {
196
- pane = captureTmuxPane(sessionName, socketName);
197
- } catch {
198
- return; // transient capture failure — retry next tick
199
- }
200
- if (claudePaneIsBusy(pane)) {
233
+ // #768: drive turn boundaries off the intrinsic probe (busy/shell→busy,
234
+ // idle→idle), falling back to the pane-scrape when the probe is unavailable.
235
+ // shell→busy means a Bash gate no longer reads as a finished turn.
236
+ const activity = this.activityFromProbeOrPane(process, sessionName, socketName);
237
+ if (activity === "busy") {
201
238
  sawBusy = true;
202
239
  idleStreak = 0;
203
240
  return;
204
241
  }
205
- idleStreak = claudePaneLooksReady(pane) ? idleStreak + 1 : 0;
242
+ idleStreak = activity === "idle" ? idleStreak + 1 : 0;
206
243
  const confirmedIdle = idleStreak >= CLAUDE_TURN_IDLE_CONFIRM_TICKS;
207
244
  // Require either an observed busy spinner or a grace window, so we never
208
245
  // declare idle in the brief gap before Claude starts rendering the turn.
@@ -503,11 +540,16 @@ function captureTmuxPane(sessionName: string, socketName?: string): string {
503
540
  return result.stdout.toString();
504
541
  }
505
542
 
506
- // FRAGILE PANE HEURISTICSboth functions below string-match Claude Code's TUI
507
- // chrome against captured tmux scrollback (~80 lines), so they break whenever CC
508
- // restyles its footer/banner. They are deliberately substring/regex based because
509
- // there's no machine-readable ready/busy signal from the TUI. Known break conditions,
510
- // so the next CC restyle is a fast fix rather than a hunt:
543
+ // LEGACY PANE-SCRAPE FALLBACK (#766/#768) busy/idle is now driven primarily by
544
+ // Claude Code's intrinsic session probe (readClaudeSessionStatus, ./claude-session-probe):
545
+ // `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
546
+ // on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
547
+ // probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
548
+ // (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
549
+ // CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
550
+ // restyles its footer/banner. Deliberately substring/regex based (no machine-readable
551
+ // signal existed when these were written). Known break conditions, so the next CC restyle
552
+ // is a fast fix rather than a hunt:
511
553
  // readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
512
554
  // "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
513
555
  // the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
@@ -13,6 +13,9 @@ interface BusyReconcilerDeps {
13
13
  hasProviderTurn(): boolean;
14
14
  canProbeActivity(): boolean;
15
15
  probeActivity(): Promise<ProviderActivity> | undefined;
16
+ // #769 P1-A: optional independent pane-busy read consulted ONLY at the force-clear
17
+ // moment. When it resolves true, a probe-`idle` must not clear the live turn.
18
+ probePaneBusy?(): Promise<boolean> | undefined;
16
19
  clearProviderTurn(reason: string): void;
17
20
  sessionDebug(message: string): void;
18
21
  }
@@ -78,6 +81,21 @@ export class BusyReconciler {
78
81
  const confirm = this.sawBusy ? BUSY_RECONCILE_IDLE_CONFIRM : BUSY_RECONCILE_IDLE_CONFIRM_NO_BUSY;
79
82
  this.deps.sessionDebug(`reconcile probe=idle sawBusy=${this.sawBusy} streak=${this.idleStreak}/${confirm}`);
80
83
  if (this.idleStreak < confirm) return;
84
+ // #769 P1-A: before force-clearing, give an independent pane-busy read a veto.
85
+ // If the pane spinner still says busy, the probe-`idle` is untrustworthy (e.g. a
86
+ // connection retry wrote status:idle mid-turn) — hold the turn instead of clearing.
87
+ // Symmetric defense-in-depth: only clear when BOTH sources agree the turn is done.
88
+ const paneBusy = this.deps.probePaneBusy?.();
89
+ if (paneBusy) {
90
+ let stillBusy = false;
91
+ try { stillBusy = (await paneBusy) === true; } catch { stillBusy = false; }
92
+ if (stillBusy) {
93
+ this.idleStreak = 0;
94
+ this.sawBusy = true;
95
+ this.deps.sessionDebug("reconcile clear vetoed: pane still reads busy");
96
+ return;
97
+ }
98
+ }
81
99
  this.disarm();
82
100
  this.deps.clearProviderTurn(this.sawBusy ? "backstop reconciler" : "backstop reconciler (no-busy-observed)");
83
101
  }
@@ -333,6 +333,9 @@ export class AgentRunner {
333
333
  probeActivity: () => this.process && this.options.adapter.probeActivity
334
334
  ? this.options.adapter.probeActivity(this.process)
335
335
  : undefined,
336
+ probePaneBusy: () => this.process && this.options.adapter.probePaneBusy
337
+ ? this.options.adapter.probePaneBusy(this.process)
338
+ : undefined,
336
339
  clearProviderTurn: (reason) => this.forceClearProviderTurn(reason),
337
340
  sessionDebug: (message) => this.sessionDebug(message),
338
341
  });