agent-relay-runner 0.10.25 → 0.10.27
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/adapter.ts
CHANGED
|
@@ -97,6 +97,37 @@ export function extractLastAssistantTurn(jsonl: string): string {
|
|
|
97
97
|
return collected.join("\n\n").trim();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Returns only the text from the LAST assistant entry in the current turn.
|
|
102
|
+
* Unlike extractLastAssistantTurn which collects all intermediate text between
|
|
103
|
+
* tool calls, this returns just the final response — what the user sees as the
|
|
104
|
+
* concluding message.
|
|
105
|
+
*/
|
|
106
|
+
export function extractFinalAssistantMessage(jsonl: string): string {
|
|
107
|
+
const lines = jsonl.split("\n");
|
|
108
|
+
let lastText = "";
|
|
109
|
+
let pastLastUserPrompt = false;
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const trimmed = line.trim();
|
|
112
|
+
if (!trimmed) continue;
|
|
113
|
+
let entry: TranscriptEntry;
|
|
114
|
+
try {
|
|
115
|
+
entry = JSON.parse(trimmed) as TranscriptEntry;
|
|
116
|
+
} catch {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (isRealUserPrompt(entry)) {
|
|
120
|
+
pastLastUserPrompt = true;
|
|
121
|
+
lastText = "";
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!pastLastUserPrompt) continue;
|
|
125
|
+
const text = assistantText(entry);
|
|
126
|
+
if (text) lastText = text;
|
|
127
|
+
}
|
|
128
|
+
return lastText.trim();
|
|
129
|
+
}
|
|
130
|
+
|
|
100
131
|
/**
|
|
101
132
|
* Extract text from the `last_assistant_message` field in the Stop hook
|
|
102
133
|
* payload. This is the content of the final assistant message — either a plain
|
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) {
|
package/src/config.ts
CHANGED
|
@@ -33,6 +33,7 @@ export function defaultProviderConfig(provider: string): ProviderConfig {
|
|
|
33
33
|
defaultCapabilities: ["chat", "code", "review"],
|
|
34
34
|
defaultApprovalMode: "guarded",
|
|
35
35
|
defaultTags: [],
|
|
36
|
+
chatCaptureMode: "final",
|
|
36
37
|
headless: {
|
|
37
38
|
tmuxPrefix: `${provider}-relay`,
|
|
38
39
|
shutdownTimeoutMs: 10_000,
|
|
@@ -63,6 +64,7 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
|
|
|
63
64
|
defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
|
|
64
65
|
defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
|
|
65
66
|
defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
|
|
67
|
+
chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
|
|
66
68
|
headless: {
|
|
67
69
|
tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
|
|
68
70
|
shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
|
|
@@ -88,6 +90,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
|
|
|
88
90
|
defaultCapabilities: config.defaultCapabilities,
|
|
89
91
|
defaultApprovalMode: config.defaultApprovalMode,
|
|
90
92
|
defaultTags: config.defaultTags,
|
|
93
|
+
chatCaptureMode: config.chatCaptureMode,
|
|
91
94
|
headless: config.headless,
|
|
92
95
|
};
|
|
93
96
|
}
|
|
@@ -120,6 +123,10 @@ function positiveInteger(value: unknown): number | undefined {
|
|
|
120
123
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
121
124
|
}
|
|
122
125
|
|
|
126
|
+
function enumValue<T extends string>(value: unknown, allowed: T[]): T | undefined {
|
|
127
|
+
return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
123
130
|
function stringArray(value: unknown): string[] | undefined {
|
|
124
131
|
return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
|
|
125
132
|
}
|
package/src/runner.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { ManagedProcess, ProviderAdapter, ProviderConfig, ProviderPermissio
|
|
|
9
9
|
import { messagesWithCachedAttachments } from "./attachment-cache";
|
|
10
10
|
import { ClaimTracker } from "./claim-tracker";
|
|
11
11
|
import { startControlServer, type ControlServer } from "./control-server";
|
|
12
|
-
import { extractLastAssistantTurn, extractHookAssistantMessage, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
12
|
+
import { extractLastAssistantTurn, extractFinalAssistantMessage, extractHookAssistantMessage, transcriptLooksComplete } from "./adapters/claude-transcript";
|
|
13
13
|
import { agentProfileProjectionReport } from "./profile-projection";
|
|
14
14
|
import { profileUsesHostProviderGlobals } from "./profile-home";
|
|
15
15
|
import { runtimeMetadata } from "./version";
|
|
@@ -702,7 +702,8 @@ export class AgentRunner {
|
|
|
702
702
|
try { jsonl = await readFile(input.transcriptPath, "utf8"); } catch { return; }
|
|
703
703
|
}
|
|
704
704
|
if (!transcriptLooksComplete(jsonl)) continue;
|
|
705
|
-
|
|
705
|
+
const extract = this.options.providerConfig.chatCaptureMode === "full" ? extractLastAssistantTurn : extractFinalAssistantMessage;
|
|
706
|
+
body = extract(jsonl);
|
|
706
707
|
if (body) break;
|
|
707
708
|
}
|
|
708
709
|
// Fallback: use last_assistant_message from the Stop hook payload directly.
|