agent-relay-runner 0.37.0 → 0.39.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.37.0",
3
+ "version": "0.39.0",
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.22"
23
+ "agent-relay-sdk": "0.2.24"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -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.37.0",
4
+ "version": "0.39.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
- import type { Message } from "agent-relay-sdk";
5
+ import { extractClaudeModelUnavailableMessage, type Message } from "agent-relay-sdk";
6
6
  import { shellEscape as shellQuote } from "agent-relay-sdk/shell-utils";
7
7
  import { tmuxCommand, tmuxHasSession } from "agent-relay-sdk/tmux-utils";
8
8
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
@@ -21,6 +21,7 @@ export class ClaudeAdapter implements ProviderAdapter {
21
21
  private statusCb: (status: ProviderStatusUpdate) => void = () => {};
22
22
  private tmuxWatcher?: Timer;
23
23
  private turnWatcher?: Timer;
24
+ private modelUnavailableReported = false;
24
25
 
25
26
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
26
27
  this.statusCb = cb;
@@ -285,6 +286,7 @@ export class ClaudeAdapter implements ProviderAdapter {
285
286
 
286
287
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
287
288
  const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
289
+ this.modelUnavailableReported = false;
288
290
 
289
291
  Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
290
292
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
@@ -332,6 +334,19 @@ export class ClaudeAdapter implements ProviderAdapter {
332
334
  clearInterval(this.tmuxWatcher!);
333
335
  this.tmuxWatcher = undefined;
334
336
  this.statusCb("offline");
337
+ return;
338
+ }
339
+ if (this.modelUnavailableReported) return;
340
+ let pane = "";
341
+ try {
342
+ pane = captureTmuxPane(sessionName, socketName);
343
+ } catch {
344
+ return;
345
+ }
346
+ const status = claudeModelUnavailableStatus(pane, sessionName);
347
+ if (status) {
348
+ this.modelUnavailableReported = true;
349
+ this.statusCb(status);
335
350
  }
336
351
  }, 2000);
337
352
  }
@@ -505,6 +520,43 @@ export function claudePaneIsBusy(text: string): boolean {
505
520
  return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
506
521
  }
507
522
 
523
+ export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
524
+ const message = extractClaudeModelUnavailableMessage(text);
525
+ if (!message) return null;
526
+ return {
527
+ status: "error",
528
+ clear: ["provider-turn", "subagent"],
529
+ providerState: {
530
+ state: "failed",
531
+ reason: "model-unavailable",
532
+ message,
533
+ source: "claude-pane",
534
+ terminal: true,
535
+ ...(sessionName ? { sessionName } : {}),
536
+ recommendedAction: "Choose a different Claude model before restarting this agent.",
537
+ },
538
+ metadata: {
539
+ terminalFailureReason: "model-unavailable",
540
+ terminalFailureMessage: message,
541
+ },
542
+ timeline: {
543
+ status: "provider.restart_decision",
544
+ id: `provider-model-unavailable-${Date.now()}`,
545
+ timestamp: Date.now(),
546
+ title: "Provider restart skipped",
547
+ body: message,
548
+ icon: "ti-player-stop",
549
+ metadata: {
550
+ eventType: "provider.restart_decision",
551
+ decision: "stop-surface",
552
+ reason: "model-unavailable",
553
+ modelUnavailable: true,
554
+ modelUnavailableMessage: message,
555
+ },
556
+ },
557
+ };
558
+ }
559
+
508
560
  async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
509
561
  const deadline = Date.now() + timeoutMs;
510
562
  while (Date.now() < deadline) {
package/src/runner.ts CHANGED
@@ -250,6 +250,7 @@ export class AgentRunner {
250
250
  // Session-mirror: a synthesized id grouping a turn's reasoning/tool steps and
251
251
  // its final response. Set when a provider-turn starts, cleared when it ends.
252
252
  private currentTurnId?: string;
253
+ private currentTurnStartedAt?: number;
253
254
  // Prompt-echo dedup: a short, time-bounded queue of prompts the runner itself
254
255
  // injected (chat box or initial prompt) that are still awaiting their matching
255
256
  // UserPromptSubmit echo. A single slot dropped earlier entries when several prompts
@@ -268,6 +269,7 @@ export class AgentRunner {
268
269
  // Tracks whether the provider is in a legitimate blocked/approval state, so the
269
270
  // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
270
271
  private providerBlocked = false;
272
+ private terminalFailure?: { reason: string; message: string; providerState?: Record<string, unknown> };
271
273
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
272
274
  // from the Claude transcript into chat as discreet session events.
273
275
  private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
@@ -1091,15 +1093,36 @@ export class AgentRunner {
1091
1093
  } else if (status === "idle") {
1092
1094
  this.providerBlocked = false;
1093
1095
  }
1096
+ if (typeof update !== "string" && status === "error") {
1097
+ const terminalReason = typeof update.metadata?.terminalFailureReason === "string"
1098
+ ? update.metadata.terminalFailureReason
1099
+ : typeof update.providerState?.reason === "string"
1100
+ ? update.providerState.reason
1101
+ : "provider-error";
1102
+ const terminalMessage = typeof update.metadata?.terminalFailureMessage === "string"
1103
+ ? update.metadata.terminalFailureMessage
1104
+ : typeof update.providerState?.message === "string"
1105
+ ? update.providerState.message
1106
+ : "Provider reported an unrecoverable error.";
1107
+ this.terminalFailure = {
1108
+ reason: terminalReason,
1109
+ message: terminalMessage,
1110
+ ...(update.providerState ? { providerState: update.providerState } : {}),
1111
+ };
1112
+ } else if (status === "idle" || status === "busy") {
1113
+ this.terminalFailure = undefined;
1114
+ }
1094
1115
  if (status === "busy" && reason === "provider-turn") {
1095
1116
  if (!this.currentTurnId) {
1096
1117
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
1118
+ this.currentTurnStartedAt = Date.now();
1097
1119
  this.sessionLog(`turn started (turn ${this.currentTurnId})`);
1098
1120
  }
1099
1121
  this.armBusyReconciler();
1100
1122
  } else if (status === "idle" && reason === "provider-turn") {
1101
1123
  if (this.currentTurnId) this.sessionLog(`turn ended via provider idle (turn ${this.currentTurnId})`);
1102
1124
  this.currentTurnId = undefined;
1125
+ this.currentTurnStartedAt = undefined;
1103
1126
  this.disarmBusyReconciler();
1104
1127
  this.stopReasoningTail();
1105
1128
  }
@@ -1324,7 +1347,10 @@ export class AgentRunner {
1324
1347
  // deliveries) so those aren't double-posted.
1325
1348
  private async handleUserPrompt(input: { prompt: string; transcriptPath?: string }): Promise<void> {
1326
1349
  if (input.transcriptPath) this.lastTranscriptPath = input.transcriptPath;
1327
- if (!this.currentTurnId) this.currentTurnId = crypto.randomUUID();
1350
+ if (!this.currentTurnId) {
1351
+ this.currentTurnId = crypto.randomUUID();
1352
+ this.currentTurnStartedAt = Date.now();
1353
+ }
1328
1354
  const text = input.prompt.trim();
1329
1355
  if (text && !this.isRunnerInjectedPrompt(text)) {
1330
1356
  this.sessionLog(`prompt echoed from terminal (${text.length} chars)`);
@@ -1482,7 +1508,7 @@ export class AgentRunner {
1482
1508
  if (pendingPrompt) {
1483
1509
  replyToMessageId = pendingPrompt;
1484
1510
  this.pendingPromptMessageId = undefined;
1485
- } else if (this.obligationCache.get().some((o) => o.from === "user")) {
1511
+ } else if (this.obligationCache.get().some((o) => o.from === "user" && this.obligationPredatesCurrentTurn(o))) {
1486
1512
  // The agent will answer the relay obligation itself — don't double-post (#196).
1487
1513
  return;
1488
1514
  }
@@ -1534,6 +1560,10 @@ export class AgentRunner {
1534
1560
  return false;
1535
1561
  }
1536
1562
 
1563
+ private obligationPredatesCurrentTurn(obligation: { createdAt: number }): boolean {
1564
+ return this.currentTurnStartedAt === undefined || obligation.createdAt <= this.currentTurnStartedAt;
1565
+ }
1566
+
1537
1567
  // --- Busy-state reconciler (item 2) -------------------------------------------------
1538
1568
  // A safety net for turns that end out of band (interrupted from the web terminal,
1539
1569
  // a hook that never fired) where the runner would otherwise stay stuck "busy".
@@ -1668,11 +1698,13 @@ export class AgentRunner {
1668
1698
  }
1669
1699
 
1670
1700
  private publishStatus(): void {
1701
+ this.claims.expire();
1671
1702
  const status = this.claims.currentStatus();
1672
1703
  const agentStatus = runnerAgentStatus(status);
1673
1704
  const activeWork = this.claims.activeWork();
1674
1705
  const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
1675
- const providerState = providerStateFromActiveWork(activeWork);
1706
+ const terminalFailure = this.terminalFailure;
1707
+ const providerState = terminalFailure?.providerState ?? providerStateFromActiveWork(activeWork);
1676
1708
  this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
1677
1709
  const timelineEvent = this.pendingTimelineEvent;
1678
1710
  this.pendingTimelineEvent = undefined;
@@ -1693,6 +1725,11 @@ export class AgentRunner {
1693
1725
  lifecycleAction: this.lifecycleAction ?? null,
1694
1726
  profile: this.options.profile ?? null,
1695
1727
  ...(status === "error" ? { terminalStatus: "error" } : {}),
1728
+ ...(terminalFailure ? {
1729
+ lastError: terminalFailure.message,
1730
+ terminalFailureReason: terminalFailure.reason,
1731
+ terminalFailureMessage: terminalFailure.message,
1732
+ } : {}),
1696
1733
  busyReasons: this.claims.reasons(),
1697
1734
  activeWork,
1698
1735
  activeSubagents,