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.0",
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.66"
23
+ "agent-relay-sdk": "0.2.67"
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.88.0",
4
+ "version": "0.88.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -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 from the
373
- // pane. Dismiss it (Escape = "wait", which just stops the turn) so the agent
374
- // returns to a clean prompt the resume `continue` can land on, then hold it.
375
- const rateLimit = claudeRateLimitStatus(pane, sessionName);
376
- if (rateLimit) {
377
- if (!this.rateLimitReported) {
378
- this.rateLimitReported = true;
379
- this.dismissRateLimitModal(sessionName, socketName);
380
- this.statusCb(rateLimit);
381
- }
382
- } else if (this.rateLimitReported) {
383
- // Modal gone (dismissed, or a real turn resumed) — re-arm for the next limit.
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, which stops the
390
- // turn (per the CC TUI) and returns the agent to a normal idle prompt. Best-effort:
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;
@@ -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 fresh = !this.rateLimitHold;
1160
- this.rateLimitHold = update.providerState;
1161
- if (fresh) this.publishRateLimitNotice(update.providerState);
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