agent-relay-runner 0.106.0 → 0.108.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.106.0",
3
+ "version": "0.108.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-providers": "0.104.0",
24
- "agent-relay-sdk": "0.2.92",
23
+ "agent-relay-providers": "0.104.1",
24
+ "agent-relay-sdk": "0.2.94",
25
25
  "callmux": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
@@ -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.106.0",
4
+ "version": "0.108.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -65,6 +65,9 @@ var claudeProviderManifest = {
65
65
  launch: {
66
66
  managedPolicyPrompt: {
67
67
  alwaysOn: "append-system-prompt"
68
+ },
69
+ taskHydration: {
70
+ channel: "append-system-prompt"
68
71
  }
69
72
  },
70
73
  catalog: {
@@ -188,6 +191,9 @@ var codexProviderManifest = {
188
191
  launch: {
189
192
  managedPolicyPrompt: {
190
193
  alwaysOn: "prompt"
194
+ },
195
+ taskHydration: {
196
+ channel: "prompt"
191
197
  }
192
198
  },
193
199
  catalog: {
@@ -0,0 +1,147 @@
1
+ import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
2
+ import type { ProviderStatusUpdate } from "../adapter";
3
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
4
+
5
+ // LEGACY PANE-SCRAPE FALLBACK (#766/#768) — busy/idle is now driven primarily by
6
+ // Claude Code's intrinsic session probe (readClaudeSessionStatus, ./claude-session-probe):
7
+ // `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
8
+ // on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
9
+ // probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
10
+ // (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
11
+ // CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
12
+ // restyles its footer/banner. Deliberately substring/regex based (no machine-readable
13
+ // signal existed when these were written). Known break conditions, so the next CC restyle
14
+ // is a fast fix rather than a hunt:
15
+ // readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
16
+ // "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
17
+ // the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
18
+ // busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner counter
19
+ // (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
20
+ // FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
21
+ // transcript discussing "esc to interrupt", or this very comment shown in a pane)
22
+ // reads as ready/busy. Tolerated because the markers are CC-specific enough to be
23
+ // rare in real output; if it bites, gate on the LAST N lines only (the live footer).
24
+ // History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
25
+ export function claudePaneLooksReady(text: string): boolean {
26
+ // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
27
+ // conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
28
+ // a run) must detect readiness from the PERSISTENT input-box footer that Claude always
29
+ // renders at the prompt. Requiring the banner made every delivery after the first screen
30
+ // of output time out as "input not ready". Any one of these strong, Claude-specific
31
+ // markers means the TUI is up and the input box exists.
32
+ return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
33
+ || text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
34
+ || text.includes("? for shortcuts") // default footer hint (persistent)
35
+ || text.includes("/effort")
36
+ || text.includes("Welcome back")
37
+ || text.includes("Claude Code");
38
+ }
39
+
40
+ // The working-spinner footer carries a live elapsed-time counter while a turn is in
41
+ // flight, e.g. "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)" — gerund, "… (", then
42
+ // "[Nh ][Nm ]Ns". Anchored on the gerund ellipsis so it can't match the "… +N lines
43
+ // (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
44
+ // "/btw … without interrupting Claude's current work" queue hint.
45
+ const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
46
+
47
+ export function claudePaneIsBusy(text: string): boolean {
48
+ // Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
49
+ // dropped that hint but kept the "(<elapsed>" counter, which is the stable busy
50
+ // signal across versions. Match either so the busy probe (and the reconciler
51
+ // backstop that depends on it) keep working as the footer wording changes.
52
+ return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
53
+ }
54
+
55
+ // #286: detect the subscription usage-limit modal in the pane and turn it into the
56
+ // provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
57
+ // at reset. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
58
+ export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
59
+ const parsed = parseClaudeRateLimitPane(text);
60
+ if (!parsed) return null;
61
+ return {
62
+ status: "idle",
63
+ clear: ["subagent"],
64
+ providerState: buildRateLimitProviderState({
65
+ errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
66
+ ...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
67
+ message: parsed.message,
68
+ source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
69
+ }),
70
+ };
71
+ }
72
+
73
+ // #769 — detect the API connection-retry banner in the live terminal footer and surface a
74
+ // transient blocked hold (non-terminal, clears on recovery). Three stable anchors from the
75
+ // banner text: "Unable to connect to API", "Retrying in <N>s", "attempt <N>/<M>".
76
+ // Gated on the LAST N lines so agent output that merely QUOTES these strings (e.g. quoting
77
+ // this comment, or a discussion of retry logic) doesn't false-positive — the live banner
78
+ // always lives in the footer; quoted strings appear in the scrollback body.
79
+ // Break condition: if CC renames/removes any anchor substring the detector stops firing and
80
+ // sessions return to idle/available — a fast fix once the new footer wording is known.
81
+ const CLAUDE_CONN_RETRY_FOOTER_LINES = 10;
82
+ const CLAUDE_CONN_RETRY_ANCHOR_RE = /Unable to connect to API/;
83
+ const CLAUDE_CONN_RETRY_RATE_RE = /Retrying in \d+s/;
84
+ const CLAUDE_CONN_RETRY_ATTEMPT_RE = /attempt \d+\/\d+/;
85
+
86
+ export function claudeConnectionRetryStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
87
+ const footer = text.split("\n").slice(-CLAUDE_CONN_RETRY_FOOTER_LINES).join("\n");
88
+ if (
89
+ !CLAUDE_CONN_RETRY_ANCHOR_RE.test(footer) ||
90
+ !CLAUDE_CONN_RETRY_RATE_RE.test(footer) ||
91
+ !CLAUDE_CONN_RETRY_ATTEMPT_RE.test(footer)
92
+ ) return null;
93
+ const now = Date.now();
94
+ return {
95
+ status: "idle",
96
+ clear: ["subagent"],
97
+ providerState: {
98
+ state: "blocked",
99
+ reason: "conn_retry",
100
+ kind: "conn_retry",
101
+ label: "connection degraded · retrying",
102
+ message: "Claude cannot reach the Anthropic API and is retrying the connection.",
103
+ source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
104
+ terminal: false,
105
+ recommendedAction: "Holding; agent-relay will clear this state once the session reconnects.",
106
+ enteredAt: now,
107
+ updatedAt: now,
108
+ },
109
+ };
110
+ }
111
+
112
+ export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
113
+ const message = extractClaudeModelUnavailableMessage(text);
114
+ if (!message) return null;
115
+ return {
116
+ status: "error",
117
+ clear: ["provider-turn", "subagent"],
118
+ providerState: {
119
+ state: "failed",
120
+ reason: "model-unavailable",
121
+ message,
122
+ source: "claude-pane",
123
+ terminal: true,
124
+ ...(sessionName ? { sessionName } : {}),
125
+ recommendedAction: "Choose a different Claude model before restarting this agent.",
126
+ },
127
+ metadata: {
128
+ terminalFailureReason: "model-unavailable",
129
+ terminalFailureMessage: message,
130
+ },
131
+ timeline: {
132
+ status: "provider.restart_decision",
133
+ id: `provider-model-unavailable-${Date.now()}`,
134
+ timestamp: Date.now(),
135
+ title: "Provider restart skipped",
136
+ body: message,
137
+ icon: "ti-player-stop",
138
+ metadata: {
139
+ eventType: "provider.restart_decision",
140
+ decision: "stop-surface",
141
+ reason: "model-unavailable",
142
+ modelUnavailable: true,
143
+ modelUnavailableMessage: message,
144
+ },
145
+ },
146
+ };
147
+ }
@@ -2,7 +2,7 @@ import { chmodSync, 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 { extractClaudeModelUnavailableMessage, type Message } from "agent-relay-sdk";
5
+ import { 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";
@@ -15,8 +15,8 @@ import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "
15
15
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
16
16
  import { claudeProviderMessageText } from "./claude-delivery";
17
17
  import { claudeProbeActivity, readManagedClaudeStatus, resolveClaudePid, type ClaudeProbeActivity } from "./claude-session-probe";
18
- import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
19
18
  import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
19
+ import { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
20
20
 
21
21
  export class ClaudeAdapter implements ProviderAdapter {
22
22
  readonly provider = "claude";
@@ -28,6 +28,7 @@ export class ClaudeAdapter implements ProviderAdapter {
28
28
  private tmuxWatcher?: Timer;
29
29
  private turnWatcher?: Timer;
30
30
  private modelUnavailableReported = false;
31
+ private connectionRetryActive = false;
31
32
  private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
32
33
 
33
34
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
@@ -347,6 +348,7 @@ export class ClaudeAdapter implements ProviderAdapter {
347
348
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
348
349
  const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
349
350
  this.modelUnavailableReported = false;
351
+ this.connectionRetryActive = false;
350
352
  this.promptGatePaneState = initialClaudePromptGatePaneState();
351
353
 
352
354
  Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
@@ -409,6 +411,18 @@ export class ClaudeAdapter implements ProviderAdapter {
409
411
  this.statusCb(status);
410
412
  return;
411
413
  }
414
+ // #769: connection-retry banner detection. Emits a transient blocked hold while the
415
+ // pane shows the retry footer; clears to idle when the banner disappears (API recovered).
416
+ const connRetryStatus = claudeConnectionRetryStatus(pane, sessionName);
417
+ if (connRetryStatus) {
418
+ if (!this.connectionRetryActive) {
419
+ this.connectionRetryActive = true;
420
+ this.statusCb(connRetryStatus);
421
+ }
422
+ } else if (this.connectionRetryActive) {
423
+ this.connectionRetryActive = false;
424
+ this.statusCb("idle");
425
+ }
412
426
  // #663: prompt-gate fallback. Known gates should be prevented before launch;
413
427
  // this catches missed/new blocking panes with the same #655 debounce + busy
414
428
  // guard used for rate-limit holds.
@@ -540,110 +554,9 @@ function captureTmuxPane(sessionName: string, socketName?: string): string {
540
554
  return result.stdout.toString();
541
555
  }
542
556
 
543
- // LEGACY PANE-SCRAPE FALLBACK (#766/#768) busy/idle is now driven primarily by
544
- // Claude Code's intrinsic session probe (readClaudeSessionStatus, ./claude-session-probe):
545
- // `$CLAUDE_CONFIG_DIR/sessions/<pid>.json` carries a machine-readable `status` field
546
- // on CC ≥2.1.119. These two pane heuristics are the SOLE fallback for pre-2.1.119 /
547
- // probe-missing sessions, and claudePaneLooksReady still backs the input-readiness gate
548
- // (waitForClaudeInputReady) + the prompt-gate busy guard — so they stay. They string-match
549
- // CC's TUI chrome against captured tmux scrollback (~80 lines), so they break whenever CC
550
- // restyles its footer/banner. Deliberately substring/regex based (no machine-readable
551
- // signal existed when these were written). Known break conditions, so the next CC restyle
552
- // is a fast fix rather than a hunt:
553
- // readiness (claudePaneLooksReady) breaks if CC renames/removes ALL of: the
554
- // "bypass permissions" / "shift+tab to cycle" / "? for shortcuts" footer hints,
555
- // the "/effort" hint, or the "Welcome back" / "Claude Code" banner.
556
- // busy (claudePaneIsBusy) breaks if CC drops the live "… (<elapsed>" spinner counter
557
- // (the cross-version anchor; the "esc to interrupt" hint was already dropped in 2.1.x).
558
- // FALSE POSITIVES: agent output that literally QUOTES any of these strings (e.g. a
559
- // transcript discussing "esc to interrupt", or this very comment shown in a pane)
560
- // reads as ready/busy. Tolerated because the markers are CC-specific enough to be
561
- // rare in real output; if it bites, gate on the LAST N lines only (the live footer).
562
- // History: 18067b5 (busy counter), and the readiness footer-vs-banner fix below.
563
- export function claudePaneLooksReady(text: string): boolean {
564
- // Claude's startup banner ("Claude Code" / "Welcome back") scrolls off the pane once the
565
- // conversation fills it, so a mid-session delivery (e.g. the budget warning, minutes into
566
- // a run) must detect readiness from the PERSISTENT input-box footer that Claude always
567
- // renders at the prompt. Requiring the banner made every delivery after the first screen
568
- // of output time out as "input not ready". Any one of these strong, Claude-specific
569
- // markers means the TUI is up and the input box exists.
570
- return text.includes("bypass permissions") // footer in --dangerously-skip-permissions mode
571
- || text.includes("shift+tab to cycle") // mode-cycle footer hint (persistent)
572
- || text.includes("? for shortcuts") // default footer hint (persistent)
573
- || text.includes("/effort")
574
- || text.includes("Welcome back")
575
- || text.includes("Claude Code");
576
- }
577
-
578
- // The working-spinner footer carries a live elapsed-time counter while a turn is in
579
- // flight, e.g. "✶ Perambulating… (2m 17s · ↓ 8.7k tokens)" — gerund, "… (", then
580
- // "[Nh ][Nm ]Ns". Anchored on the gerund ellipsis so it can't match the "… +N lines
581
- // (ctrl+o to expand)" truncation marker, the idle input box, or the persistent
582
- // "/btw … without interrupting Claude's current work" queue hint.
583
- const CLAUDE_BUSY_SPINNER_RE = /…\s*\((?:\d+h\s+)?(?:\d+m\s+)?\d+s\b/;
584
-
585
- export function claudePaneIsBusy(text: string): boolean {
586
- // Claude Code <2.1.x rendered "(esc to interrupt)" in the spinner footer; 2.1.x
587
- // dropped that hint but kept the "(<elapsed>" counter, which is the stable busy
588
- // signal across versions. Match either so the busy probe (and the reconciler
589
- // backstop that depends on it) keep working as the footer wording changes.
590
- return CLAUDE_BUSY_SPINNER_RE.test(text) || text.includes("esc to interrupt");
591
- }
592
-
593
- // #286: detect the subscription usage-limit modal in the pane and turn it into the
594
- // provider-neutral rate-limit hold (idle + blocked) that the relay's resume sweep lifts
595
- // at reset. Mirrors claudeModelUnavailableStatus, but non-terminal (idle, not error).
596
- export function claudeRateLimitStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
597
- const parsed = parseClaudeRateLimitPane(text);
598
- if (!parsed) return null;
599
- return {
600
- status: "idle",
601
- clear: ["subagent"],
602
- providerState: buildRateLimitProviderState({
603
- errorType: parsed.errorType ?? "session_limit", kind: parsed.kind ?? "usage_limit",
604
- ...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
605
- message: parsed.message,
606
- source: sessionName ? `claude-pane:${sessionName}` : "claude-pane",
607
- }),
608
- };
609
- }
610
-
611
- export function claudeModelUnavailableStatus(text: string, sessionName?: string): ProviderStatusUpdate | null {
612
- const message = extractClaudeModelUnavailableMessage(text);
613
- if (!message) return null;
614
- return {
615
- status: "error",
616
- clear: ["provider-turn", "subagent"],
617
- providerState: {
618
- state: "failed",
619
- reason: "model-unavailable",
620
- message,
621
- source: "claude-pane",
622
- terminal: true,
623
- ...(sessionName ? { sessionName } : {}),
624
- recommendedAction: "Choose a different Claude model before restarting this agent.",
625
- },
626
- metadata: {
627
- terminalFailureReason: "model-unavailable",
628
- terminalFailureMessage: message,
629
- },
630
- timeline: {
631
- status: "provider.restart_decision",
632
- id: `provider-model-unavailable-${Date.now()}`,
633
- timestamp: Date.now(),
634
- title: "Provider restart skipped",
635
- body: message,
636
- icon: "ti-player-stop",
637
- metadata: {
638
- eventType: "provider.restart_decision",
639
- decision: "stop-surface",
640
- reason: "model-unavailable",
641
- modelUnavailable: true,
642
- modelUnavailableMessage: message,
643
- },
644
- },
645
- };
646
- }
557
+ // Re-export pane-status detectors (moved to ./claude-status-detectors) so existing
558
+ // import paths (including claude.test.ts) continue to resolve via this module.
559
+ export { claudePaneLooksReady, claudePaneIsBusy, claudeRateLimitStatus, claudeConnectionRetryStatus, claudeModelUnavailableStatus } from "./claude-status-detectors";
647
560
 
648
561
  async function waitForClaudeInputReady(sessionName: string, timeoutMs = CLAUDE_TMUX_READY_TIMEOUT_MS, socketName?: string): Promise<void> {
649
562
  const deadline = Date.now() + timeoutMs;