agent-relay-runner 0.101.0 → 0.101.2
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.
|
|
3
|
+
"version": "0.101.2",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"directory": "runner"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"agent-relay-sdk": "0.2.
|
|
23
|
+
"agent-relay-sdk": "0.2.83"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
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
|
+
}
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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 =
|
|
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
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
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.
|
package/src/busy-reconciler.ts
CHANGED
|
@@ -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
|
}
|
package/src/runner-core.ts
CHANGED
|
@@ -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
|
});
|