agent-relay-runner 0.10.26 → 0.11.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
package/src/adapters/claude.ts
CHANGED
|
@@ -10,6 +10,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
10
10
|
readonly provider = "claude";
|
|
11
11
|
private statusCb: (status: ProviderStatusUpdate) => void = () => {};
|
|
12
12
|
private tmuxWatcher?: Timer;
|
|
13
|
+
private turnWatcher?: Timer;
|
|
13
14
|
|
|
14
15
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
15
16
|
this.statusCb = cb;
|
|
@@ -88,6 +89,59 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
88
89
|
// interaction scaffolding (reply reminder, claim-task instruction) — the agent has
|
|
89
90
|
// no way to /reply or /claim, so it's just confusing noise in a clean-room run.
|
|
90
91
|
await submitTextToTmux(session, claudeProviderMessageText(messages, { deliveryCount: 1, relaySurface: !monitorless }), socket);
|
|
92
|
+
// Monitorless = no Stop/UserPromptSubmit hooks, so the runner gets no turn
|
|
93
|
+
// lifecycle and never marks the claimed task done (the way Codex's app-server
|
|
94
|
+
// status does) — the task lingers until the runtime budget cancels it. Scrape
|
|
95
|
+
// the tmux pane for the busy→idle transition to synthesize a provider-turn
|
|
96
|
+
// signal so isolated automation agents reach task "done" like Codex.
|
|
97
|
+
if (monitorless) this.beginMonitorlessTurnWatch(session, socket);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private beginMonitorlessTurnWatch(sessionName: string, socketName?: string): void {
|
|
101
|
+
this.stopTurnWatch();
|
|
102
|
+
// We just submitted a prompt, so a turn is starting. Emit a synthetic
|
|
103
|
+
// provider-turn busy now so the runner arms task-claim completion; the claim
|
|
104
|
+
// for this delivery is already registered before deliver() runs.
|
|
105
|
+
this.statusCb({ status: "busy", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
|
|
106
|
+
let ticks = 0;
|
|
107
|
+
let idleStreak = 0;
|
|
108
|
+
let sawBusy = false;
|
|
109
|
+
this.turnWatcher = setInterval(() => {
|
|
110
|
+
ticks += 1;
|
|
111
|
+
if (!tmuxHasSession(sessionName, socketName) || ticks > CLAUDE_TURN_MAX_TICKS) {
|
|
112
|
+
// Session ended (the tmux watcher emits offline) or we hit the safety cap;
|
|
113
|
+
// stop without emitting idle and let budget/server reconcile resolve it.
|
|
114
|
+
this.stopTurnWatch();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
let pane: string;
|
|
118
|
+
try {
|
|
119
|
+
pane = captureTmuxPane(sessionName, socketName);
|
|
120
|
+
} catch {
|
|
121
|
+
return; // transient capture failure — retry next tick
|
|
122
|
+
}
|
|
123
|
+
if (claudePaneIsBusy(pane)) {
|
|
124
|
+
sawBusy = true;
|
|
125
|
+
idleStreak = 0;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
idleStreak = claudePaneLooksReady(pane) ? idleStreak + 1 : 0;
|
|
129
|
+
const confirmedIdle = idleStreak >= CLAUDE_TURN_IDLE_CONFIRM_TICKS;
|
|
130
|
+
// Require either an observed busy spinner or a grace window, so we never
|
|
131
|
+
// declare idle in the brief gap before Claude starts rendering the turn.
|
|
132
|
+
const turnObserved = sawBusy || ticks >= CLAUDE_TURN_QUICK_GRACE_TICKS;
|
|
133
|
+
if (confirmedIdle && turnObserved) {
|
|
134
|
+
this.stopTurnWatch();
|
|
135
|
+
this.statusCb({ status: "idle", reason: "provider-turn", id: CLAUDE_TURN_WATCH_ID });
|
|
136
|
+
}
|
|
137
|
+
}, CLAUDE_TURN_POLL_MS);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private stopTurnWatch(): void {
|
|
141
|
+
if (this.turnWatcher) {
|
|
142
|
+
clearInterval(this.turnWatcher);
|
|
143
|
+
this.turnWatcher = undefined;
|
|
144
|
+
}
|
|
91
145
|
}
|
|
92
146
|
|
|
93
147
|
async deliverInitialPrompt(process: ManagedProcess, prompt: string): Promise<void> {
|
|
@@ -228,6 +282,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
228
282
|
}
|
|
229
283
|
|
|
230
284
|
private async shutdownTmux(sessionName: string, opts: { graceful: boolean; timeoutMs: number }, socketName?: string): Promise<void> {
|
|
285
|
+
this.stopTurnWatch();
|
|
231
286
|
if (this.tmuxWatcher) {
|
|
232
287
|
clearInterval(this.tmuxWatcher);
|
|
233
288
|
this.tmuxWatcher = undefined;
|
|
@@ -253,6 +308,12 @@ export const CLAUDE_EXIT_COMMAND = "/exit";
|
|
|
253
308
|
export const CLAUDE_TMUX_SUBMIT_DELAY_MS = 250;
|
|
254
309
|
export const CLAUDE_TMUX_SUBMIT_KEY = "C-m";
|
|
255
310
|
export const CLAUDE_TMUX_READY_TIMEOUT_MS = 10_000;
|
|
311
|
+
// Monitorless turn detection (Fix: isolated agents reach task "done" like Codex).
|
|
312
|
+
const CLAUDE_TURN_WATCH_ID = "tmux-turn";
|
|
313
|
+
const CLAUDE_TURN_POLL_MS = 1500;
|
|
314
|
+
const CLAUDE_TURN_IDLE_CONFIRM_TICKS = 3; // ~4.5s of stable idle before declaring done
|
|
315
|
+
const CLAUDE_TURN_QUICK_GRACE_TICKS = 8; // ~12s fallback when the spinner was never caught
|
|
316
|
+
const CLAUDE_TURN_MAX_TICKS = 3600; // ~90min safety cap so the timer never leaks
|
|
256
317
|
|
|
257
318
|
export function claudeShutdownGraceMs(timeoutMs: number): number {
|
|
258
319
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return 10_000;
|
|
@@ -360,6 +421,14 @@ export function claudePaneLooksReady(text: string): boolean {
|
|
|
360
421
|
|| text.includes("Claude Code");
|
|
361
422
|
}
|
|
362
423
|
|
|
424
|
+
export function claudePaneIsBusy(text: string): boolean {
|
|
425
|
+
// Claude renders "(esc to interrupt)" in its working spinner footer while a turn
|
|
426
|
+
// is in flight and removes it once the turn completes and the input box is idle.
|
|
427
|
+
// The persistent "…without interrupting Claude" queue hint does NOT contain this
|
|
428
|
+
// exact phrase, so it won't false-positive.
|
|
429
|
+
return text.includes("esc to interrupt");
|
|
430
|
+
}
|
|
431
|
+
|
|
363
432
|
async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
|
|
364
433
|
const deadline = Date.now() + timeoutMs;
|
|
365
434
|
while (Date.now() < deadline) {
|