agent-relay-runner 0.105.2 → 0.107.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 +2 -2
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/plugins/claude/monitors/relay-monitor.provisioned.mjs +15 -3
- package/src/adapter.ts +14 -3
- package/src/adapters/claude-delivery.ts +5 -4
- package/src/adapters/claude-status-detectors.ts +147 -0
- package/src/adapters/claude.ts +19 -106
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.107.0",
|
|
4
4
|
"description": "Unified provider lifecycle runner for Agent Relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"agent-relay-providers": "0.104.0",
|
|
24
|
-
"agent-relay-sdk": "0.2.
|
|
24
|
+
"agent-relay-sdk": "0.2.93",
|
|
25
25
|
"callmux": "0.23.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
@@ -1035,6 +1035,17 @@ var NOTIFICATION_NUDGE = "\u21AA Notification \u2014 no reply needed.";
|
|
|
1035
1035
|
function isNotificationMessage(message) {
|
|
1036
1036
|
return isPersistedRelayMessage(message) && message.replyExpected === false;
|
|
1037
1037
|
}
|
|
1038
|
+
function dedupePersistedMessages(messages2) {
|
|
1039
|
+
const seen = new Set;
|
|
1040
|
+
return messages2.filter((message) => {
|
|
1041
|
+
if (!isPersistedRelayMessage(message))
|
|
1042
|
+
return true;
|
|
1043
|
+
if (seen.has(message.id))
|
|
1044
|
+
return false;
|
|
1045
|
+
seen.add(message.id);
|
|
1046
|
+
return true;
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1038
1049
|
function providerAttachmentText(message) {
|
|
1039
1050
|
const refs = attachmentRefs(message);
|
|
1040
1051
|
if (!refs.length)
|
|
@@ -1165,11 +1176,12 @@ function replyReminder(message, readOnly) {
|
|
|
1165
1176
|
}
|
|
1166
1177
|
function claudeProviderMessageText(messages2, options) {
|
|
1167
1178
|
const relaySurface = options.relaySurface !== false;
|
|
1168
|
-
const
|
|
1169
|
-
const
|
|
1179
|
+
const uniqueMessages = dedupePersistedMessages(messages2);
|
|
1180
|
+
const sections = uniqueMessages.map((message) => formatMessage(message, relaySurface));
|
|
1181
|
+
const replyable = latestReplyableMessage(uniqueMessages);
|
|
1170
1182
|
if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
|
|
1171
1183
|
sections.push(replyReminder(replyable, options.readOnly === true));
|
|
1172
|
-
} else if (relaySurface && !replyable &&
|
|
1184
|
+
} else if (relaySurface && !replyable && uniqueMessages.some(isNotificationMessage)) {
|
|
1173
1185
|
sections.push(NOTIFICATION_NUDGE);
|
|
1174
1186
|
}
|
|
1175
1187
|
return sections.join(`
|
package/src/adapter.ts
CHANGED
|
@@ -304,6 +304,16 @@ function latestReplyableMessage(messages: Message[]): Message | undefined {
|
|
|
304
304
|
.at(-1);
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
export function dedupePersistedMessages(messages: Message[]): Message[] {
|
|
308
|
+
const seen = new Set<number>();
|
|
309
|
+
return messages.filter((message) => {
|
|
310
|
+
if (!isPersistedRelayMessage(message)) return true;
|
|
311
|
+
if (seen.has(message.id)) return false;
|
|
312
|
+
seen.add(message.id);
|
|
313
|
+
return true;
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
307
317
|
function providerSenderLabel(message: Message): string {
|
|
308
318
|
if (message.from === "user") return "human user";
|
|
309
319
|
const source = isRecord(message.payload?.source) ? message.payload.source : undefined;
|
|
@@ -356,9 +366,10 @@ export function providerAttachmentText(message: Message): string | undefined {
|
|
|
356
366
|
}
|
|
357
367
|
|
|
358
368
|
export function providerMessageText(messages: Message[]): string {
|
|
359
|
-
const
|
|
369
|
+
const uniqueMessages = dedupePersistedMessages(messages);
|
|
370
|
+
const replyable = latestReplyableMessage(uniqueMessages);
|
|
360
371
|
const maxChars = providerMessageBodyMaxChars();
|
|
361
|
-
const sections =
|
|
372
|
+
const sections = uniqueMessages
|
|
362
373
|
.map((message) => {
|
|
363
374
|
const subject = message.subject ? `Subject: ${message.subject}\n` : "";
|
|
364
375
|
const isMemoryContext = isMemoryInjection(message);
|
|
@@ -404,7 +415,7 @@ export function providerMessageText(messages: Message[]): string {
|
|
|
404
415
|
"If you already delivered the useful response through Relay, do not send a separate status-only confirmation.",
|
|
405
416
|
"If multiple messages arrived together, cover them in one reply instead of answering each line separately.",
|
|
406
417
|
].join("\n"));
|
|
407
|
-
} else if (
|
|
418
|
+
} else if (uniqueMessages.some(isNotificationMessage)) {
|
|
408
419
|
// #283 — pure notification batch: no scaffold, just the one-line no-reply nudge.
|
|
409
420
|
sections.push(NOTIFICATION_NUDGE);
|
|
410
421
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Message } from "agent-relay-sdk";
|
|
2
2
|
import { isRecord } from "agent-relay-sdk";
|
|
3
|
-
import { isNotificationMessage, NOTIFICATION_NUDGE, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
|
|
3
|
+
import { dedupePersistedMessages, isNotificationMessage, NOTIFICATION_NUDGE, providerAttachmentText, providerMessageBodyMaxChars } from "../adapter";
|
|
4
4
|
import { relayReplyReminderText } from "../relay-instructions";
|
|
5
5
|
|
|
6
6
|
const REMINDER_EVERY_DELIVERIES = 5;
|
|
@@ -126,12 +126,13 @@ function replyReminder(message: Message, readOnly: boolean): string {
|
|
|
126
126
|
|
|
127
127
|
export function claudeProviderMessageText(messages: Message[], options: ClaudeDeliveryTextOptions): string {
|
|
128
128
|
const relaySurface = options.relaySurface !== false;
|
|
129
|
-
const
|
|
130
|
-
const
|
|
129
|
+
const uniqueMessages = dedupePersistedMessages(messages);
|
|
130
|
+
const sections = uniqueMessages.map((message) => formatMessage(message, relaySurface));
|
|
131
|
+
const replyable = latestReplyableMessage(uniqueMessages);
|
|
131
132
|
// Isolated agents have no way to reply through Relay — never append the reminder/nudge.
|
|
132
133
|
if (relaySurface && replyable && shouldShowReplyReminder(options.deliveryCount)) {
|
|
133
134
|
sections.push(replyReminder(replyable, options.readOnly === true));
|
|
134
|
-
} else if (relaySurface && !replyable &&
|
|
135
|
+
} else if (relaySurface && !replyable && uniqueMessages.some(isNotificationMessage)) {
|
|
135
136
|
// #283 — pure notification batch (no message wants a reply): drop the scaffold, append the
|
|
136
137
|
// one-line nudge so a long context can't make the agent forget the session-start no-reply rule.
|
|
137
138
|
sections.push(NOTIFICATION_NUDGE);
|
|
@@ -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
|
+
}
|
package/src/adapters/claude.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
544
|
-
//
|
|
545
|
-
|
|
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;
|