agent-relay-runner 0.88.1 → 0.88.2
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 +1 -1
- package/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/adapters/claude.ts +16 -16
- package/src/rate-limit.ts +32 -0
- package/src/runner-core.ts +33 -5
package/package.json
CHANGED
package/src/adapters/claude.ts
CHANGED
|
@@ -12,7 +12,7 @@ import type { SessionEvent } from "../session-insights";
|
|
|
12
12
|
import { prepareClaudeProfileHome, profileUsesHostProviderGlobals } from "../profile-home";
|
|
13
13
|
import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
|
|
14
14
|
import { claudeProviderMessageText } from "./claude-delivery";
|
|
15
|
-
import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
|
|
15
|
+
import { buildRateLimitProviderState, gatedClaudeRateLimitStatus, parseClaudeRateLimitPane } from "../rate-limit";
|
|
16
16
|
|
|
17
17
|
export class ClaudeAdapter implements ProviderAdapter {
|
|
18
18
|
readonly provider = "claude";
|
|
@@ -27,6 +27,7 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
27
27
|
// #286: true while a usage-limit modal is being held, so the pane watcher dismisses
|
|
28
28
|
// it + emits the hold once (not every 2s tick). Cleared when the modal leaves the pane.
|
|
29
29
|
private rateLimitReported = false;
|
|
30
|
+
private rateLimitPaneDetections = 0;
|
|
30
31
|
|
|
31
32
|
onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
|
|
32
33
|
this.statusCb = cb;
|
|
@@ -369,25 +370,24 @@ export class ClaudeAdapter implements ProviderAdapter {
|
|
|
369
370
|
this.statusCb(status);
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
|
-
// #286: the subscription usage-limit modal fires no hook, so detect it
|
|
373
|
-
// pane.
|
|
374
|
-
//
|
|
375
|
-
const rateLimit =
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
this.rateLimitReported = false;
|
|
373
|
+
// #286/#655: the subscription usage-limit modal fires no hook, so detect it
|
|
374
|
+
// from the pane. Act only after consecutive non-busy detections; a live turn
|
|
375
|
+
// mentioning quota/reset text must not be escaped or held.
|
|
376
|
+
const rateLimit = gatedClaudeRateLimitStatus(pane, {
|
|
377
|
+
consecutiveDetections: this.rateLimitPaneDetections,
|
|
378
|
+
reported: this.rateLimitReported,
|
|
379
|
+
}, { sessionName, busy: claudePaneIsBusy(pane) });
|
|
380
|
+
this.rateLimitPaneDetections = rateLimit.state.consecutiveDetections;
|
|
381
|
+
this.rateLimitReported = rateLimit.state.reported;
|
|
382
|
+
if (rateLimit.status) {
|
|
383
|
+
this.dismissRateLimitModal(sessionName, socketName);
|
|
384
|
+
this.statusCb(rateLimit.status);
|
|
385
385
|
}
|
|
386
386
|
}, 2000);
|
|
387
387
|
}
|
|
388
388
|
|
|
389
|
-
// Send Escape to dismiss the usage-limit modal — the "wait" choice,
|
|
390
|
-
//
|
|
389
|
+
// Send Escape to dismiss the confirmed usage-limit modal — the "wait" choice,
|
|
390
|
+
// which returns the agent to a normal idle prompt. Best-effort:
|
|
391
391
|
// a failed send-keys must never wedge the pane watcher.
|
|
392
392
|
private dismissRateLimitModal(sessionName: string, socketName?: string): void {
|
|
393
393
|
try {
|
package/src/rate-limit.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// #286 — shared rate-limit hold construction + Claude pane detection.
|
|
2
|
+
import type { ProviderStatusUpdate } from "./adapter";
|
|
2
3
|
//
|
|
3
4
|
// Two detection arms feed the SAME provider-neutral `blocked` hold:
|
|
4
5
|
// 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
|
|
@@ -94,6 +95,37 @@ export function parseClaudeRateLimitPane(text: string, nowMs: number = Date.now(
|
|
|
94
95
|
return null;
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
export interface ClaudeRateLimitPaneGateState {
|
|
99
|
+
consecutiveDetections: number;
|
|
100
|
+
reported: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function gatedClaudeRateLimitStatus(
|
|
104
|
+
text: string,
|
|
105
|
+
state: ClaudeRateLimitPaneGateState,
|
|
106
|
+
options: { sessionName?: string; busy?: boolean } = {},
|
|
107
|
+
): { status: ProviderStatusUpdate | null; state: ClaudeRateLimitPaneGateState } {
|
|
108
|
+
const parsed = parseClaudeRateLimitPane(text);
|
|
109
|
+
if (!parsed) return { status: null, state: { consecutiveDetections: 0, reported: false } };
|
|
110
|
+
if (options.busy) return { status: null, state: { consecutiveDetections: 0, reported: state.reported } };
|
|
111
|
+
const nextState = { consecutiveDetections: state.consecutiveDetections + 1, reported: state.reported };
|
|
112
|
+
if (nextState.reported || nextState.consecutiveDetections < 2) return { status: null, state: nextState };
|
|
113
|
+
nextState.reported = true;
|
|
114
|
+
return {
|
|
115
|
+
status: {
|
|
116
|
+
status: "idle",
|
|
117
|
+
clear: ["subagent"],
|
|
118
|
+
providerState: buildRateLimitProviderState({
|
|
119
|
+
errorType: "session_limit",
|
|
120
|
+
...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
|
|
121
|
+
message: parsed.message,
|
|
122
|
+
source: options.sessionName ? `claude-pane:${options.sessionName}` : "claude-pane",
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
state: nextState,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
97
129
|
// The clean limit line containing the match offset, stripped of box-drawing chrome.
|
|
98
130
|
function limitLineAt(text: string, index: number): string {
|
|
99
131
|
const start = text.lastIndexOf("\n", index) + 1;
|
package/src/runner-core.ts
CHANGED
|
@@ -264,6 +264,7 @@ export class AgentRunner {
|
|
|
264
264
|
// providerState; it clears when a new turn starts (the relay's resume message
|
|
265
265
|
// wakes one) so the blocked badge lifts on its own.
|
|
266
266
|
private rateLimitHold?: Record<string, unknown>;
|
|
267
|
+
private pendingRateLimitHold?: Record<string, unknown>;
|
|
267
268
|
// Reasoning tailer (item 5): streams the in-flight turn's reasoning/tool steps
|
|
268
269
|
// from the Claude transcript into chat as discreet session events.
|
|
269
270
|
private reasoningTail?: ReasoningTailState;
|
|
@@ -1150,16 +1151,28 @@ export class AgentRunner {
|
|
|
1150
1151
|
...(update.timeline.metadata ? { metadata: update.timeline.metadata } : {}),
|
|
1151
1152
|
};
|
|
1152
1153
|
}
|
|
1154
|
+
let deferredRateLimitHold = false;
|
|
1153
1155
|
if (typeof update !== "string" && update.providerState) {
|
|
1154
|
-
const ps = update.providerState as { state?: unknown; reason?: unknown };
|
|
1155
|
-
this.providerBlocked = ps.state === "blocked";
|
|
1156
|
+
const ps = update.providerState as { state?: unknown; reason?: unknown; source?: unknown };
|
|
1156
1157
|
// A rate-limit hold persists across the idle the turn ends on; any other
|
|
1157
1158
|
// providerState supersedes it (clears a stale hold).
|
|
1158
1159
|
if (ps.state === "blocked" && ps.reason === "rate_limit") {
|
|
1159
|
-
const
|
|
1160
|
-
this.
|
|
1161
|
-
|
|
1160
|
+
const paneRateLimitHold = typeof ps.source === "string" && ps.source.startsWith("claude-pane");
|
|
1161
|
+
if (status === "idle" && reason === "provider-turn" && paneRateLimitHold && this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
|
|
1162
|
+
this.pendingRateLimitHold = update.providerState;
|
|
1163
|
+
this.providerBlocked = false;
|
|
1164
|
+
deferredRateLimitHold = true;
|
|
1165
|
+
this.sessionLog(`rate-limit hold deferred until active turn finishes (turn ${this.currentTurnId ?? "?"})`);
|
|
1166
|
+
} else {
|
|
1167
|
+
this.providerBlocked = true;
|
|
1168
|
+
this.pendingRateLimitHold = undefined;
|
|
1169
|
+
const fresh = !this.rateLimitHold;
|
|
1170
|
+
this.rateLimitHold = update.providerState;
|
|
1171
|
+
if (fresh) this.publishRateLimitNotice(update.providerState);
|
|
1172
|
+
}
|
|
1162
1173
|
} else {
|
|
1174
|
+
this.providerBlocked = ps.state === "blocked";
|
|
1175
|
+
this.pendingRateLimitHold = undefined;
|
|
1163
1176
|
this.rateLimitHold = undefined;
|
|
1164
1177
|
}
|
|
1165
1178
|
} else if (status === "idle") {
|
|
@@ -1167,6 +1180,13 @@ export class AgentRunner {
|
|
|
1167
1180
|
}
|
|
1168
1181
|
// Forward progress (a real turn) lifts the hold so the blocked badge clears.
|
|
1169
1182
|
if (status === "busy") this.rateLimitHold = undefined;
|
|
1183
|
+
if (deferredRateLimitHold) {
|
|
1184
|
+
if (typeof update !== "string") {
|
|
1185
|
+
for (const kind of update.clear ?? []) this.claims.clearWorkKind(kind);
|
|
1186
|
+
}
|
|
1187
|
+
this.publishStatus();
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1170
1190
|
if (typeof update !== "string" && status === "error") {
|
|
1171
1191
|
const terminalReason = typeof update.metadata?.terminalFailureReason === "string"
|
|
1172
1192
|
? update.metadata.terminalFailureReason
|
|
@@ -1253,6 +1273,14 @@ export class AgentRunner {
|
|
|
1253
1273
|
if (status === "idle" && this.pendingInitialPrompt && !this.deliveringInitialPrompt) {
|
|
1254
1274
|
void this.attemptInitialPromptDelivery(this.pendingInitialPrompt, INITIAL_PROMPT_RETRY_READY_TIMEOUT_MS);
|
|
1255
1275
|
}
|
|
1276
|
+
if (status === "idle" && this.pendingRateLimitHold && !this.claims.activeWork().some((work) => work.kind === "provider-turn")) {
|
|
1277
|
+
const hold = this.pendingRateLimitHold;
|
|
1278
|
+
this.pendingRateLimitHold = undefined;
|
|
1279
|
+
this.providerBlocked = true;
|
|
1280
|
+
const fresh = !this.rateLimitHold;
|
|
1281
|
+
this.rateLimitHold = hold;
|
|
1282
|
+
if (fresh) this.publishRateLimitNotice(hold);
|
|
1283
|
+
}
|
|
1256
1284
|
this.publishStatus();
|
|
1257
1285
|
}
|
|
1258
1286
|
|