agent-relay-runner 0.38.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.38.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.23"
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.38.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
@@ -269,6 +269,7 @@ export class AgentRunner {
269
269
  // Tracks whether the provider is in a legitimate blocked/approval state, so the
270
270
  // busy reconciler doesn't mistake a permission prompt for a stuck-busy turn.
271
271
  private providerBlocked = false;
272
+ private terminalFailure?: { reason: string; message: string; providerState?: Record<string, unknown> };
272
273
  // Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
273
274
  // from the Claude transcript into chat as discreet session events.
274
275
  private reasoningTail?: { timer: ReturnType<typeof setInterval>; seen: Set<string> };
@@ -1092,6 +1093,25 @@ export class AgentRunner {
1092
1093
  } else if (status === "idle") {
1093
1094
  this.providerBlocked = false;
1094
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
+ }
1095
1115
  if (status === "busy" && reason === "provider-turn") {
1096
1116
  if (!this.currentTurnId) {
1097
1117
  this.currentTurnId = typeof update !== "string" && update.id ? update.id : crypto.randomUUID();
@@ -1683,7 +1703,8 @@ export class AgentRunner {
1683
1703
  const agentStatus = runnerAgentStatus(status);
1684
1704
  const activeWork = this.claims.activeWork();
1685
1705
  const activeSubagents = activeWork.filter((item) => item.kind === "subagent");
1686
- const providerState = providerStateFromActiveWork(activeWork);
1706
+ const terminalFailure = this.terminalFailure;
1707
+ const providerState = terminalFailure?.providerState ?? providerStateFromActiveWork(activeWork);
1687
1708
  this.bus.setSemanticStatus(status === "offline" || status === "error" ? "idle" : status);
1688
1709
  const timelineEvent = this.pendingTimelineEvent;
1689
1710
  this.pendingTimelineEvent = undefined;
@@ -1704,6 +1725,11 @@ export class AgentRunner {
1704
1725
  lifecycleAction: this.lifecycleAction ?? null,
1705
1726
  profile: this.options.profile ?? null,
1706
1727
  ...(status === "error" ? { terminalStatus: "error" } : {}),
1728
+ ...(terminalFailure ? {
1729
+ lastError: terminalFailure.message,
1730
+ terminalFailureReason: terminalFailure.reason,
1731
+ terminalFailureMessage: terminalFailure.message,
1732
+ } : {}),
1707
1733
  busyReasons: this.claims.reasons(),
1708
1734
  activeWork,
1709
1735
  activeSubagents,