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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.10.25",
3
+ "version": "0.10.27",
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.10.25",
4
+ "version": "0.10.27",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -31,6 +31,7 @@ export interface ProviderConfig {
31
31
  defaultCapabilities: string[];
32
32
  defaultApprovalMode: string;
33
33
  defaultTags: string[];
34
+ chatCaptureMode: "final" | "full";
34
35
  headless: {
35
36
  tmuxPrefix: string;
36
37
  shutdownTimeoutMs: number;
@@ -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
@@ -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
- body = extractLastAssistantTurn(jsonl);
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.