agent-relay-runner 0.88.0 → 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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-runner",
|
|
3
|
-
"version": "0.88.
|
|
3
|
+
"version": "0.88.2",
|
|
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
|
+
"agent-relay-sdk": "0.2.67"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
26
|
"@types/bun": "latest",
|
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 {
|
|
@@ -5,17 +5,21 @@ import { DEFAULT_PROVIDER_QUOTA_CONFIG, normalizeProviderQuotaConfig, resolveCla
|
|
|
5
5
|
|
|
6
6
|
const PASSIVE_QUOTA_CONFIG_REFRESH_MS = 60_000;
|
|
7
7
|
const PASSIVE_QUOTA_SAMPLE_STALE_MS = 10 * 60_000;
|
|
8
|
+
const PASSIVE_QUOTA_LEASE_MAX_TTL_MS = 10 * 60_000;
|
|
9
|
+
const PASSIVE_QUOTA_LEASE_PADDING_MS = 60_000;
|
|
8
10
|
|
|
9
11
|
interface ClaudeQuotaHarvestOptions {
|
|
10
12
|
agentId: string;
|
|
11
13
|
provider: string;
|
|
12
|
-
http: () => Pick<RelayHttpClient, "getProviderQuotaConfig" | "upsertProviderQuota">;
|
|
14
|
+
http: () => Pick<RelayHttpClient, "acquireProviderQuotaPublisherLease" | "getProviderQuotaConfig" | "upsertProviderQuota">;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export class ClaudeQuotaHarvest {
|
|
16
18
|
private config = { ...DEFAULT_PROVIDER_QUOTA_CONFIG };
|
|
17
19
|
private configRefreshAt = 0;
|
|
18
20
|
private lastReportAt?: number;
|
|
21
|
+
private lease?: { accountKey: string; leaseToken: string; expiresAt: number };
|
|
22
|
+
private nextLeaseAttemptAt?: number;
|
|
19
23
|
|
|
20
24
|
constructor(private readonly options: ClaudeQuotaHarvestOptions) {}
|
|
21
25
|
|
|
@@ -30,6 +34,7 @@ export class ClaudeQuotaHarvest {
|
|
|
30
34
|
if (this.lastReportAt !== undefined && now - this.lastReportAt < config.pollIntervalMs) return;
|
|
31
35
|
const identity = await resolveClaudeStatuslineQuotaIdentity({ env: process.env });
|
|
32
36
|
if (!identity) return;
|
|
37
|
+
if (!await this.ensurePublisherLease(identity.accountKey, config.pollIntervalMs, now)) return;
|
|
33
38
|
await this.options.http().upsertProviderQuota({
|
|
34
39
|
provider: "claude",
|
|
35
40
|
accountKey: identity.accountKey,
|
|
@@ -55,6 +60,27 @@ export class ClaudeQuotaHarvest {
|
|
|
55
60
|
}
|
|
56
61
|
return this.config;
|
|
57
62
|
}
|
|
63
|
+
|
|
64
|
+
private async ensurePublisherLease(accountKey: string, pollIntervalMs: number, now: number): Promise<boolean> {
|
|
65
|
+
if (this.lease?.accountKey === accountKey && this.lease.expiresAt > now) return true;
|
|
66
|
+
if (this.nextLeaseAttemptAt !== undefined && now < this.nextLeaseAttemptAt) return false;
|
|
67
|
+
const leaseToken = this.lease?.accountKey === accountKey ? this.lease.leaseToken : undefined;
|
|
68
|
+
const result = await this.options.http().acquireProviderQuotaPublisherLease({
|
|
69
|
+
provider: "claude",
|
|
70
|
+
accountKey,
|
|
71
|
+
sourceAgentId: this.options.agentId,
|
|
72
|
+
...(leaseToken ? { leaseToken } : {}),
|
|
73
|
+
ttlMs: Math.min(PASSIVE_QUOTA_LEASE_MAX_TTL_MS, pollIntervalMs + PASSIVE_QUOTA_LEASE_PADDING_MS),
|
|
74
|
+
});
|
|
75
|
+
if (!result.acquired || !result.lease) {
|
|
76
|
+
this.lease = undefined;
|
|
77
|
+
this.nextLeaseAttemptAt = now + Math.min(result.retryAfterMs ?? pollIntervalMs, pollIntervalMs);
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
this.lease = { accountKey, leaseToken: result.lease.leaseToken, expiresAt: result.lease.expiresAt };
|
|
81
|
+
this.nextLeaseAttemptAt = undefined;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
58
84
|
}
|
|
59
85
|
|
|
60
86
|
function freshQuotaFromProbeMetrics(metrics: NonNullable<ReturnType<typeof readRunnerContextProbeState>>, now: number): QuotaState | undefined {
|
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
|
|