agent-relay-runner 0.91.4 → 0.92.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.91.4",
3
+ "version": "0.92.0",
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.71"
23
+ "agent-relay-sdk": "0.2.72"
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.91.4",
4
+ "version": "0.92.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -12,7 +12,8 @@ import type { SessionEvent } from "../session-insights";
12
12
  import { prepareClaudeProfileHome, profileUsesClaudeHostProviderGlobals } from "../profile-home";
13
13
  import { assembleLaunch, materializeLaunchAssembly, sessionStatusLineSettingsArgs } from "../launch-assembly";
14
14
  import { claudeProviderMessageText } from "./claude-delivery";
15
- import { buildRateLimitProviderState, gatedClaudeRateLimitStatus, parseClaudeRateLimitPane } from "../rate-limit";
15
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "../rate-limit";
16
+ import { evaluateClaudePromptGatePane, initialClaudePromptGatePaneState, type ClaudePromptGatePaneState } from "../claude-prompt-gates";
16
17
 
17
18
  export class ClaudeAdapter implements ProviderAdapter {
18
19
  readonly provider = "claude";
@@ -24,10 +25,7 @@ export class ClaudeAdapter implements ProviderAdapter {
24
25
  private tmuxWatcher?: Timer;
25
26
  private turnWatcher?: Timer;
26
27
  private modelUnavailableReported = false;
27
- // #286: true while a usage-limit modal is being held, so the pane watcher dismisses
28
- // it + emits the hold once (not every 2s tick). Cleared when the modal leaves the pane.
29
- private rateLimitReported = false;
30
- private rateLimitPaneDetections = 0;
28
+ private promptGatePaneState: ClaudePromptGatePaneState = initialClaudePromptGatePaneState();
31
29
 
32
30
  onStatusChange(cb: (status: ProviderStatusUpdate) => void): void {
33
31
  this.statusCb = cb;
@@ -309,6 +307,7 @@ export class ClaudeAdapter implements ProviderAdapter {
309
307
  private async spawnHeadless(config: RunnerSpawnConfig, spawnArgs: SpawnArgs): Promise<ManagedProcess> {
310
308
  const { sessionName, socketName, args: tmuxArgs } = this.buildTmuxArgs(config, spawnArgs);
311
309
  this.modelUnavailableReported = false;
310
+ this.promptGatePaneState = initialClaudePromptGatePaneState();
312
311
 
313
312
  Bun.spawnSync(tmuxCommand(socketName, "kill-session", "-t", sessionName), {
314
313
  stdin: "ignore", stdout: "ignore", stderr: "ignore",
@@ -370,30 +369,27 @@ export class ClaudeAdapter implements ProviderAdapter {
370
369
  this.statusCb(status);
371
370
  return;
372
371
  }
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);
372
+ // #663: prompt-gate fallback. Known gates should be prevented before launch;
373
+ // this catches missed/new blocking panes with the same #655 debounce + busy
374
+ // guard used for rate-limit holds.
375
+ const promptGate = evaluateClaudePromptGatePane(pane, this.promptGatePaneState, {
376
+ sessionName,
377
+ busy: claudePaneIsBusy(pane),
378
+ });
379
+ this.promptGatePaneState = promptGate.state;
380
+ if (promptGate.action) {
381
+ this.sendPromptGateKeystroke(sessionName, promptGate.action.keystroke, socketName);
382
+ if (promptGate.status) this.statusCb(promptGate.status);
385
383
  }
386
384
  }, 2000);
387
385
  }
388
386
 
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
- // a failed send-keys must never wedge the pane watcher.
392
- private dismissRateLimitModal(sessionName: string, socketName?: string): void {
387
+ // Best-effort: a failed auto-dismiss must not wedge the pane watcher.
388
+ private sendPromptGateKeystroke(sessionName: string, keystroke: string, socketName?: string): void {
393
389
  try {
394
- Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, "Escape"), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
390
+ Bun.spawnSync(tmuxCommand(socketName, "send-keys", "-t", sessionName, keystroke), { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
395
391
  } catch {
396
- // ignore the hold still publishes; worst case the modal lingers until the next tick.
392
+ // ignore; the timeline/status still publishes, and the next watcher tick can observe whether the pane moved.
397
393
  }
398
394
  }
399
395
 
@@ -101,7 +101,29 @@ export class CodexAdapter implements ProviderAdapter {
101
101
  const client = process.meta?.client as CodexAppClient | undefined;
102
102
  if (!client?.isConnected()) return "unknown";
103
103
  const threadId = typeof process.meta?.threadId === "string" ? process.meta.threadId : "";
104
- if (!this.activeTurnId) return "idle";
104
+ // #651: when no local activeTurnId, don't short-circuit to idle — query the app-server.
105
+ // turn/started can arrive after processing begins (or be missed entirely); a stale-idle
106
+ // probe would make a busy agent appear idle to the coordinator. Same fallback as #653's
107
+ // ensureMainTurnActive path, but for the pull (probe) direction, not the push (event) one.
108
+ if (!this.activeTurnId) {
109
+ if (!threadId) return "idle";
110
+ try {
111
+ const read = await client.threadRead(threadId, true);
112
+ const thread = isRecord(read.thread) ? read.thread : undefined;
113
+ const turns = Array.isArray(thread?.turns) ? thread.turns : [];
114
+ const inProgressTurn = turns.find((t) => isRecord(t) && stringValue(t.status) === "inProgress");
115
+ if (inProgressTurn) {
116
+ // Synthesize the active turn (same path as ensureMainTurnActive on item events).
117
+ this.ensureMainTurnActive(stringValue(inProgressTurn.id));
118
+ return "busy";
119
+ }
120
+ const threadStatus = statusType(thread?.status);
121
+ if (threadStatus === "active") return "busy";
122
+ return "idle";
123
+ } catch {
124
+ return "unknown";
125
+ }
126
+ }
105
127
  if (!threadId) return "busy";
106
128
  try {
107
129
  const read = await client.threadRead(threadId, true);
@@ -0,0 +1,268 @@
1
+ import { resolve } from "node:path";
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import type { ProviderStatusUpdate, RunnerSpawnConfig } from "./adapter";
4
+ import { buildRateLimitProviderState, parseClaudeRateLimitPane } from "./rate-limit";
5
+
6
+ export interface ClaudeConfigPromptGatePrevention {
7
+ id: string;
8
+ description: string;
9
+ apply(seed: Record<string, unknown>, context: { host?: Record<string, unknown>; config: RunnerSpawnConfig }): void;
10
+ }
11
+
12
+ export interface ClaudeLaunchPromptGatePrevention {
13
+ id: string;
14
+ description: string;
15
+ apply(config: RunnerSpawnConfig): { strictMcpConfig?: boolean };
16
+ }
17
+
18
+ type PaneGateMatch = { message?: string; resetAt?: number; kind?: "usage_limit" | "transient_overload"; errorType?: string };
19
+
20
+ interface ClaudePanePromptGate {
21
+ id: string;
22
+ label: string;
23
+ policy: string;
24
+ keystroke: string;
25
+ minDetections: number;
26
+ match(text: string, nowMs?: number): PaneGateMatch | null;
27
+ providerState?(match: PaneGateMatch, context: ClaudePromptGatePaneContext): Record<string, unknown>;
28
+ }
29
+
30
+ export interface ClaudePromptGatePaneState {
31
+ currentGateId?: string;
32
+ consecutiveDetections: number;
33
+ handledGateId?: string;
34
+ }
35
+
36
+ export interface ClaudePromptGatePaneContext {
37
+ sessionName?: string;
38
+ busy?: boolean;
39
+ nowMs?: number;
40
+ }
41
+
42
+ export interface ClaudePromptGateAction {
43
+ gateId: string;
44
+ label: string;
45
+ policy: string;
46
+ keystroke: string;
47
+ source: "claude-pane";
48
+ }
49
+
50
+ export interface ClaudePromptGatePaneResult {
51
+ action?: ClaudePromptGateAction;
52
+ status?: ProviderStatusUpdate;
53
+ state: ClaudePromptGatePaneState;
54
+ }
55
+
56
+ export const claudeConfigPromptGatePreventions: ClaudeConfigPromptGatePrevention[] = [
57
+ {
58
+ id: "first-run-onboarding",
59
+ description: "Pre-accept Claude Code's first-run onboarding gate.",
60
+ apply(seed, { host }) {
61
+ seed.hasCompletedOnboarding = true;
62
+ if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
63
+ },
64
+ },
65
+ {
66
+ id: "theme-selection",
67
+ description: "Pre-seed a theme so the first-run theme picker does not render.",
68
+ apply(seed, { host }) {
69
+ seed.theme = typeof host?.theme === "string" ? host.theme : "dark";
70
+ },
71
+ },
72
+ {
73
+ id: "bypass-permissions-mode",
74
+ description: "Pre-accept Claude's open-approval bypass-permissions modal.",
75
+ apply(seed, { config }) {
76
+ if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
77
+ },
78
+ },
79
+ {
80
+ id: "workspace-trust",
81
+ description: "Trust the managed spawn cwd and clear project onboarding.",
82
+ apply(seed, { config }) {
83
+ if (!config.cwd) return;
84
+ seed.projects = {
85
+ ...(isRecord(seed.projects) ? seed.projects : {}),
86
+ [resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
87
+ };
88
+ },
89
+ },
90
+ ];
91
+
92
+ export const claudeLaunchPromptGatePreventions: ClaudeLaunchPromptGatePrevention[] = [
93
+ {
94
+ id: "project-mcp-discovery",
95
+ description: "Use strict controlled MCP config so Claude never prompts for ambient project .mcp.json servers.",
96
+ apply() {
97
+ return { strictMcpConfig: true };
98
+ },
99
+ },
100
+ ];
101
+
102
+ const panePromptGates: ClaudePanePromptGate[] = [
103
+ {
104
+ id: "usage-limit",
105
+ label: "Usage limit",
106
+ policy: "wait-for-reset",
107
+ keystroke: "Escape",
108
+ minDetections: 2,
109
+ match: (text, nowMs) => parseClaudeRateLimitPane(text, nowMs),
110
+ providerState(match, context) {
111
+ return buildRateLimitProviderState({
112
+ errorType: match.errorType ?? "session_limit",
113
+ kind: match.kind ?? "usage_limit",
114
+ ...(match.resetAt ? { resetAt: match.resetAt } : {}),
115
+ message: match.message,
116
+ source: context.sessionName ? `claude-pane:${context.sessionName}` : "claude-pane",
117
+ });
118
+ },
119
+ },
120
+ {
121
+ id: "workspace-trust",
122
+ label: "Workspace trust",
123
+ policy: "accept-trusted-workspace",
124
+ keystroke: "Enter",
125
+ minDetections: 2,
126
+ match: (text) => /do you trust (?:the files in )?(?:this folder|this project|this directory)/i.test(text)
127
+ && /\b(?:yes|proceed|trust|continue)\b/i.test(text)
128
+ ? { message: lineMatching(text, /do you trust/i) }
129
+ : null,
130
+ },
131
+ {
132
+ id: "bypass-permissions-mode",
133
+ label: "Bypass permissions mode",
134
+ policy: "accept-configured-open-approval",
135
+ keystroke: "Enter",
136
+ minDetections: 2,
137
+ match: (text) => /bypass permissions mode/i.test(text) && /\b(?:accept|proceed|continue|yes)\b/i.test(text)
138
+ ? { message: lineMatching(text, /bypass permissions mode/i) }
139
+ : null,
140
+ },
141
+ {
142
+ id: "project-mcp-discovery",
143
+ label: "Project MCP discovery",
144
+ policy: "reject-ambient-project-mcp",
145
+ keystroke: "Escape",
146
+ minDetections: 2,
147
+ match: (text) => /\bnew MCP servers? found\b/i.test(text) && /\b(?:select|enable|servers?)\b/i.test(text)
148
+ ? { message: lineMatching(text, /new MCP servers? found/i) }
149
+ : null,
150
+ },
151
+ {
152
+ id: "theme-selection",
153
+ label: "Theme selection",
154
+ policy: "accept-default-theme",
155
+ keystroke: "Enter",
156
+ minDetections: 2,
157
+ match: (text) => /choose (?:the )?(?:text style|theme).*Claude Code/i.test(text) && /\b(?:dark|light) mode\b/i.test(text)
158
+ ? { message: lineMatching(text, /choose/i) }
159
+ : null,
160
+ },
161
+ {
162
+ id: "first-run-onboarding",
163
+ label: "First-run onboarding",
164
+ policy: "accept-onboarding",
165
+ keystroke: "Enter",
166
+ minDetections: 2,
167
+ match: (text) => /welcome to Claude Code/i.test(text) && /\b(?:let'?s get started|continue|get started)\b/i.test(text)
168
+ ? { message: lineMatching(text, /welcome to Claude Code/i) }
169
+ : null,
170
+ },
171
+ ];
172
+
173
+ export function applyClaudeConfigPromptGatePreventions(
174
+ seed: Record<string, unknown>,
175
+ host: Record<string, unknown> | undefined,
176
+ config: RunnerSpawnConfig,
177
+ ): Record<string, unknown> {
178
+ const next = { ...seed };
179
+ for (const prevention of claudeConfigPromptGatePreventions) prevention.apply(next, { host, config });
180
+ return next;
181
+ }
182
+
183
+ export function claudeLaunchPromptGateSettings(config: RunnerSpawnConfig): { strictMcpConfig: boolean } {
184
+ return {
185
+ strictMcpConfig: claudeLaunchPromptGatePreventions.some((prevention) => prevention.apply(config).strictMcpConfig === true),
186
+ };
187
+ }
188
+
189
+ export function initialClaudePromptGatePaneState(): ClaudePromptGatePaneState {
190
+ return { consecutiveDetections: 0 };
191
+ }
192
+
193
+ export function evaluateClaudePromptGatePane(
194
+ text: string,
195
+ state: ClaudePromptGatePaneState,
196
+ context: ClaudePromptGatePaneContext = {},
197
+ ): ClaudePromptGatePaneResult {
198
+ const detected = firstDetectedPaneGate(text, context.nowMs);
199
+ if (!detected) return { state: initialClaudePromptGatePaneState() };
200
+ if (context.busy) {
201
+ return { state: { ...state, currentGateId: undefined, consecutiveDetections: 0 } };
202
+ }
203
+ if (state.handledGateId === detected.entry.id) {
204
+ return { state };
205
+ }
206
+
207
+ const consecutiveDetections = state.currentGateId === detected.entry.id ? state.consecutiveDetections + 1 : 1;
208
+ const nextState: ClaudePromptGatePaneState = {
209
+ currentGateId: detected.entry.id,
210
+ consecutiveDetections,
211
+ ...(state.handledGateId ? { handledGateId: state.handledGateId } : {}),
212
+ };
213
+ if (consecutiveDetections < detected.entry.minDetections) return { state: nextState };
214
+
215
+ const action: ClaudePromptGateAction = {
216
+ gateId: detected.entry.id,
217
+ label: detected.entry.label,
218
+ policy: detected.entry.policy,
219
+ keystroke: detected.entry.keystroke,
220
+ source: "claude-pane",
221
+ };
222
+ return {
223
+ action,
224
+ status: promptGateStatus(detected.entry, detected.match, action, context),
225
+ state: { ...nextState, handledGateId: detected.entry.id },
226
+ };
227
+ }
228
+
229
+ function firstDetectedPaneGate(text: string, nowMs?: number): { entry: ClaudePanePromptGate; match: PaneGateMatch } | null {
230
+ for (const entry of panePromptGates) {
231
+ const match = entry.match(text, nowMs);
232
+ if (match) return { entry, match };
233
+ }
234
+ return null;
235
+ }
236
+
237
+ function promptGateStatus(
238
+ entry: ClaudePanePromptGate,
239
+ match: PaneGateMatch,
240
+ action: ClaudePromptGateAction,
241
+ context: ClaudePromptGatePaneContext,
242
+ ): ProviderStatusUpdate {
243
+ const metadata = {
244
+ eventType: "claude.prompt_gate.auto_handled",
245
+ gateId: action.gateId,
246
+ policy: action.policy,
247
+ keystroke: action.keystroke,
248
+ source: action.source,
249
+ ...(context.sessionName ? { sessionName: context.sessionName } : {}),
250
+ };
251
+ return {
252
+ status: "idle",
253
+ clear: ["subagent"],
254
+ ...(entry.providerState ? { providerState: entry.providerState(match, context) } : {}),
255
+ timeline: {
256
+ status: "claude.prompt_gate.auto_handled",
257
+ id: `claude-prompt-gate-${entry.id}-${Date.now()}`,
258
+ timestamp: Date.now(),
259
+ title: "Claude prompt gate auto-handled",
260
+ body: match.message ?? `${entry.label} dismissed with ${entry.keystroke}`,
261
+ metadata,
262
+ },
263
+ };
264
+ }
265
+
266
+ function lineMatching(text: string, pattern: RegExp): string | undefined {
267
+ return text.split(/\r?\n/).map((line) => line.trim()).find((line) => pattern.test(line));
268
+ }
@@ -13,6 +13,7 @@ import {
13
13
  profileUsesClaudeHostProviderGlobals,
14
14
  providerHomePathFor,
15
15
  } from "./profile-home";
16
+ import { claudeLaunchPromptGateSettings } from "./claude-prompt-gates";
16
17
  import {
17
18
  materializeResolvedAssets,
18
19
  resolveClaudeProjectSkills,
@@ -280,16 +281,10 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
280
281
  : {};
281
282
  const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
282
283
 
283
- // #662 — EVERY managed Claude launch is --strict-mcp-config, not just vanilla. A managed
284
- // agent runs headless (no human at the keyboard), so Claude's blocking "N new MCP servers
285
- // found in this project select to enable" modal (raised on UNDECIDED cwd `.mcp.json`
286
- // servers) wedges the session forever, burning quota. Relay owns MCP: everything relay
287
- // decides should load is injected via --mcp-config, which strict PRESERVES; strict only
288
- // blocks what relay DIDN'T inject — above all the cwd project `.mcp.json` (the modal's
289
- // source), which stays excluded. The relay plugin (monitor + turn hooks) loads via
290
- // --plugin-dir and ships no MCP server, so it is untouched. Was `vanilla`-only, which left
291
- // host/minimal/isolated bases exposed to the modal wedge.
292
- const strictMcp = true;
284
+ // #662/#663strict controlled MCP is a Claude prompt-gate prevention entry, not a
285
+ // launch-assembly special case. It prevents the blocking "N new MCP servers found in
286
+ // this project" modal while preserving every relay-declared server in --mcp-config.
287
+ const strictMcp = claudeLaunchPromptGateSettings(config).strictMcpConfig;
293
288
 
294
289
  // #662 follow-up — strict drops ALL on-disk config, including the host user-scope
295
290
  // `~/.claude.json` MCP that a host-base agent loaded ambiently before strict. That
@@ -6,6 +6,7 @@ import type { ProvisioningMcpServer } from "agent-relay-sdk";
6
6
  import { profileAllowsRelayFeature, type RunnerSpawnConfig } from "./adapter";
7
7
  import { CLAUDE_RELAY_MANUAL } from "./relay-instructions";
8
8
  import { providerHomeRootFromEnv } from "./config";
9
+ import { applyClaudeConfigPromptGatePreventions } from "./claude-prompt-gates";
9
10
 
10
11
  type ProviderHome = {
11
12
  path: string;
@@ -101,20 +102,7 @@ function seedClaudeConfigIfMissing(claudeHome: string, config: RunnerSpawnConfig
101
102
  const path = join(claudeHome, ".claude.json");
102
103
  if (existsSync(path)) return;
103
104
  const host = readHostClaudeConfig();
104
- const seed: Record<string, unknown> = {
105
- hasCompletedOnboarding: true,
106
- theme: typeof host?.theme === "string" ? host.theme : "dark",
107
- };
108
- if (typeof host?.lastOnboardingVersion === "string") seed.lastOnboardingVersion = host.lastOnboardingVersion;
109
- // Open approval = headless launch adds --dangerously-skip-permissions, which has
110
- // its OWN first-run gate ("Bypass Permissions mode" accept/exit). Pre-accept it,
111
- // or the agent stalls there exactly like the onboarding/trust gates.
112
- if (config.approvalMode === "open") seed.bypassPermissionsModeAccepted = true;
113
- if (config.cwd) {
114
- seed.projects = {
115
- [resolve(config.cwd)]: { hasTrustDialogAccepted: true, hasCompletedProjectOnboarding: true },
116
- };
117
- }
105
+ const seed = applyClaudeConfigPromptGatePreventions({}, host, config);
118
106
  writeFileSync(path, JSON.stringify(seed, null, 2), { mode: 0o600 });
119
107
  }
120
108
 
package/src/rate-limit.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  // #286 — shared rate-limit hold construction + Claude pane detection.
2
- import type { ProviderStatusUpdate } from "./adapter";
3
2
  //
4
3
  // Two detection arms feed the SAME provider-neutral `blocked` hold:
5
4
  // 1. The StopFailure hook (clean API-error turn-end: API-429/auth/billing) → control-server.
@@ -126,38 +125,6 @@ function parseClaudeTransientOverloadPane(text: string): PaneRateLimit | null {
126
125
  };
127
126
  }
128
127
 
129
- export interface ClaudeRateLimitPaneGateState {
130
- consecutiveDetections: number;
131
- reported: boolean;
132
- }
133
-
134
- export function gatedClaudeRateLimitStatus(
135
- text: string,
136
- state: ClaudeRateLimitPaneGateState,
137
- options: { sessionName?: string; busy?: boolean } = {},
138
- ): { status: ProviderStatusUpdate | null; state: ClaudeRateLimitPaneGateState } {
139
- const parsed = parseClaudeRateLimitPane(text);
140
- if (!parsed) return { status: null, state: { consecutiveDetections: 0, reported: false } };
141
- if (options.busy) return { status: null, state: { consecutiveDetections: 0, reported: state.reported } };
142
- const nextState = { consecutiveDetections: state.consecutiveDetections + 1, reported: state.reported };
143
- if (nextState.reported || nextState.consecutiveDetections < 2) return { status: null, state: nextState };
144
- nextState.reported = true;
145
- return {
146
- status: {
147
- status: "idle",
148
- clear: ["subagent"],
149
- providerState: buildRateLimitProviderState({
150
- errorType: parsed.errorType ?? "session_limit",
151
- kind: parsed.kind ?? "usage_limit",
152
- ...(parsed.resetAt ? { resetAt: parsed.resetAt } : {}),
153
- message: parsed.message,
154
- source: options.sessionName ? `claude-pane:${options.sessionName}` : "claude-pane",
155
- }),
156
- },
157
- state: nextState,
158
- };
159
- }
160
-
161
128
  // The clean limit line containing the match offset, stripped of box-drawing chrome.
162
129
  function limitLineAt(text: string, index: number): string {
163
130
  const start = text.lastIndexOf("\n", index) + 1;