@bridge4dev/runner 0.40.0 → 0.41.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.
@@ -6,6 +6,7 @@ import { log } from '../log.js';
6
6
  import { mcpConfigPath } from '../paths.js';
7
7
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
8
8
  import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
+ import { clampPercent, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
9
10
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
11
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
11
12
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
@@ -682,6 +683,93 @@ class ClaudeSession {
682
683
  })
683
684
  .catch((error) => log.warn('claude: context usage probe failed', { error: String(error) }));
684
685
  }
686
+ /**
687
+ * Plan windows as `system:init` reported them (#279).
688
+ *
689
+ * Kept so `rate_limit_event`, which speaks about ONE window at a time, has
690
+ * something to update rather than replace: an event about the five-hour
691
+ * window must not erase what we know about the weekly one.
692
+ */
693
+ rateLimitWindows = new Map();
694
+ ratePlanType = null;
695
+ rateLimitsAvailable = false;
696
+ /** A refusal seen since the last turn ended, waiting to be reported with it. */
697
+ limitBlockPending = false;
698
+ /** Read the flag and clear it: one refusal marks exactly one turn end. */
699
+ consumeLimitBlock() {
700
+ const blocked = this.limitBlockPending;
701
+ this.limitBlockPending = false;
702
+ return blocked;
703
+ }
704
+ emitRateLimits(blocked = null) {
705
+ this.emit({
706
+ type: 'rate_limits',
707
+ limits: {
708
+ blocked,
709
+ available: this.rateLimitsAvailable,
710
+ planType: this.ratePlanType,
711
+ measuredAt: new Date().toISOString(),
712
+ // Ordered by our own key, not by insertion: the popup lists the short
713
+ // window above the long one, and that must not depend on which one the
714
+ // CLI happened to mention first.
715
+ windows: [...this.rateLimitWindows.values()].sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) -
716
+ (b.windowMinutes ?? Number.MAX_SAFE_INTEGER)),
717
+ },
718
+ });
719
+ }
720
+ emitRateLimitsFromInit(msg) {
721
+ const init = msg;
722
+ this.rateLimitsAvailable = init.rate_limits_available === true;
723
+ this.ratePlanType = typeof init.subscription_type === 'string' ? init.subscription_type : null;
724
+ this.rateLimitWindows.clear();
725
+ const limits = init.rate_limits;
726
+ if (this.rateLimitsAvailable && limits && typeof limits === 'object') {
727
+ for (const [name, window] of Object.entries(limits)) {
728
+ if (!window || typeof window.utilization !== 'number')
729
+ continue;
730
+ const key = rateWindowKey(name);
731
+ this.rateLimitWindows.set(name, {
732
+ key,
733
+ windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
734
+ usedPercent: clampPercent(window.utilization),
735
+ resetsAt: typeof window.resets_at === 'string' ? window.resets_at : null,
736
+ });
737
+ }
738
+ }
739
+ this.emitRateLimits();
740
+ }
741
+ onRateLimitEvent(msg) {
742
+ const info = msg.rate_limit_info;
743
+ if (!info || typeof info !== 'object')
744
+ return;
745
+ const utilization = info['utilization'];
746
+ // `rejected` is the whole subject of #258 and it is worth reporting even
747
+ // without a percentage: the refusal is the fact, the number is decoration.
748
+ // `allowed_warning` is NOT a refusal — the turn ran.
749
+ const refused = info['status'] === 'rejected';
750
+ if (typeof utilization !== 'number' && !refused)
751
+ return;
752
+ // A live event proves there IS a plan: the SDK only emits this for
753
+ // claude.ai subscriptions. An account that reached `system:init` before
754
+ // its profile scope resolved would otherwise stay `available: false`.
755
+ this.rateLimitsAvailable = true;
756
+ const name = typeof info['rateLimitType'] === 'string' ? info['rateLimitType'] : 'five_hour';
757
+ const key = rateWindowKey(name);
758
+ // `resetsAt` here is epoch SECONDS, unlike `resets_at` on init which is
759
+ // already ISO. Same fact, two encodings, one place that knows it.
760
+ const resetsAt = typeof info['resetsAt'] === 'number' ? new Date(info['resetsAt'] * 1000).toISOString() : null;
761
+ if (typeof utilization === 'number') {
762
+ this.rateLimitWindows.set(name, {
763
+ key,
764
+ windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
765
+ usedPercent: clampPercent(utilization),
766
+ resetsAt,
767
+ });
768
+ }
769
+ if (refused)
770
+ this.limitBlockPending = true;
771
+ this.emitRateLimits(refused ? { key, resetsAt } : null);
772
+ }
685
773
  /** Fire-and-forget capability refresh — never throws into the caller. */
686
774
  refreshCapabilities() {
687
775
  if (this.capabilitiesInFlight || this.stopped)
@@ -1792,6 +1880,11 @@ class ClaudeSession {
1792
1880
  // The live model can differ from what we asked for (alias
1793
1881
  // resolution, fallback) — refresh the pickers when it does.
1794
1882
  this.adoptLiveModel(msg.model);
1883
+ // #279: the plan windows ride along on `system:init`, which is the
1884
+ // only place they arrive without asking. Emitted even when the
1885
+ // account has none — «this organization runs on its own key» is an
1886
+ // answer the popup has to be able to give.
1887
+ this.emitRateLimitsFromInit(msg);
1795
1888
  }
1796
1889
  else if (msg.subtype === 'status') {
1797
1890
  const status = msg.status;
@@ -1819,6 +1912,14 @@ class ClaudeSession {
1819
1912
  this.emit({ type: 'notice', level: 'info', text: 'Context cleared (/clear)' });
1820
1913
  break;
1821
1914
  }
1915
+ // #279: the SDK pushes this whenever the numbers move, so the popup
1916
+ // stays current without us polling anything. It carries ONE window —
1917
+ // whichever is closest to its ceiling — so it updates that window and
1918
+ // leaves the others as `system:init` reported them.
1919
+ case 'rate_limit_event': {
1920
+ this.onRateLimitEvent(msg);
1921
+ break;
1922
+ }
1822
1923
  case 'assistant': {
1823
1924
  // Ticket #186. A subagent's own prose is NOT this session's answer.
1824
1925
  //
@@ -1929,10 +2030,24 @@ class ClaudeSession {
1929
2030
  // A turn the user stopped is not a failed turn. Reporting it as
1930
2031
  // one moved the session to FAILED, which is terminal — pressing
1931
2032
  // Stop cost people the session they meant to keep.
1932
- this.emit({ type: 'turn_end', ok: true, ...(aborted ? { aborted: true } : {}) });
2033
+ this.emit({
2034
+ type: 'turn_end',
2035
+ ok: true,
2036
+ ...(aborted ? { aborted: true } : {}),
2037
+ // #258: a refused turn can end EITHER way depending on where the
2038
+ // SDK gave up, so the flag is carried on both branches. The API
2039
+ // reads it to keep the streak counter honest — a turn that was
2040
+ // refused is not a turn that ran.
2041
+ ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2042
+ });
1933
2043
  }
1934
2044
  else {
1935
- this.emit({ type: 'turn_end', ok: false, errorMessage: failure });
2045
+ this.emit({
2046
+ type: 'turn_end',
2047
+ ok: false,
2048
+ errorMessage: failure,
2049
+ ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2050
+ });
1936
2051
  }
1937
2052
  break;
1938
2053
  }
@@ -6,6 +6,7 @@ import { repairCodexAuth } from './codex-home.js';
6
6
  import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
8
  import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, } from './types.js';
9
+ import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
9
10
  import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
10
11
  // Codex adapter over `codex app-server` (stage C). The normalized AgentEvent
11
12
  // contract is unchanged, so the dashboard renders Codex sessions with the same
@@ -23,7 +24,6 @@ const OPT_OUT_NOTIFICATIONS = [
23
24
  'item/agentMessage/delta',
24
25
  'item/plan/delta',
25
26
  'item/commandExecution/outputDelta',
26
- 'account/rateLimits/updated',
27
27
  ];
28
28
  /**
29
29
  * `on-request` is the only policy that lets the agent ASK.
@@ -1117,6 +1117,14 @@ class CodexSession {
1117
1117
  }
1118
1118
  return;
1119
1119
  }
1120
+ // #279. Removed from `OPT_OUT_NOTIFICATIONS` on purpose: it is not a
1121
+ // delta stream — Codex sends it when a turn changes the account's usage,
1122
+ // which is orders of magnitude rarer than the text deltas that list
1123
+ // defends against.
1124
+ case 'account/rateLimits/updated': {
1125
+ this.onRateLimits(params);
1126
+ return;
1127
+ }
1120
1128
  case 'thread/settings/updated': {
1121
1129
  const collab = asRecord(params['collaborationMode']);
1122
1130
  const model = str(params['model']);
@@ -1351,6 +1359,82 @@ class CodexSession {
1351
1359
  return;
1352
1360
  }
1353
1361
  }
1362
+ /**
1363
+ * Codex's plan windows → the shared shape (#279).
1364
+ *
1365
+ * Codex describes a window by its LENGTH (`windowMinutes: 300`) where Claude
1366
+ * names it, and calls them `primary` / `secondary` rather than by duration —
1367
+ * so the position in the snapshot tells us nothing and the minutes tell us
1368
+ * everything. `rate-limits.ts` owns that mapping for every adapter.
1369
+ *
1370
+ * Both spellings are accepted for each field. The app-server bindings are
1371
+ * camelCase, the Rust struct behind them is snake_case, and this adapter has
1372
+ * been bitten before by bindings that understate what the wire actually
1373
+ * carries (see the note at the top of this file). Reading both costs one
1374
+ * `??` and removes a whole class of «works until Codex 0.14» failure.
1375
+ */
1376
+ /** The last snapshot, kept so a refusal can borrow its reset time (#258). */
1377
+ rateLimitWindows = [];
1378
+ ratePlanType = null;
1379
+ onRateLimits(params) {
1380
+ const snapshot = asRecord(params['rateLimits'] ?? params['rate_limits'] ?? params);
1381
+ const windows = [];
1382
+ for (const slot of ['primary', 'secondary']) {
1383
+ const window = asRecord(snapshot[slot]);
1384
+ const usedPercent = num(window['usedPercent'] ?? window['used_percent']);
1385
+ if (usedPercent === undefined)
1386
+ continue;
1387
+ const minutes = num(window['windowMinutes'] ?? window['window_minutes']) ?? null;
1388
+ windows.push({
1389
+ key: rateWindowKeyFromMinutes(minutes),
1390
+ windowMinutes: minutes,
1391
+ usedPercent: clampPercent(usedPercent),
1392
+ resetsAt: str(window['resetsAt'] ?? window['resets_at']) ?? null,
1393
+ });
1394
+ }
1395
+ this.rateLimitWindows = windows.sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER));
1396
+ this.ratePlanType = str(snapshot['planType'] ?? snapshot['plan_type']) ?? null;
1397
+ this.emitRateLimits();
1398
+ }
1399
+ emitRateLimits(blocked = null) {
1400
+ // An empty snapshot is «no plan», not «nothing happened»: Codex sends this
1401
+ // for API-key accounts too, and the popup has to be able to say so.
1402
+ this.emit({
1403
+ type: 'rate_limits',
1404
+ limits: {
1405
+ blocked,
1406
+ available: this.rateLimitWindows.length > 0,
1407
+ planType: this.ratePlanType,
1408
+ measuredAt: new Date().toISOString(),
1409
+ windows: this.rateLimitWindows,
1410
+ },
1411
+ });
1412
+ }
1413
+ /**
1414
+ * Was this failure a plan refusal — and if so, when does it lift? (#258)
1415
+ *
1416
+ * Matched on the machine-readable markers Codex carries (`rateLimitReached`,
1417
+ * `usage_limit_reached`, `rate_limit_reached_type`) rather than on the
1418
+ * sentence shown to a human: the wording changes between releases, and #258
1419
+ * asks explicitly that a CLI update must never produce a WRONG wake-up.
1420
+ *
1421
+ * The reset time is taken from the last snapshot's fullest window, not from
1422
+ * the error. If no snapshot has ever arrived, the refusal is still reported —
1423
+ * with `resetsAt: null`, on which the API declines to schedule anything and
1424
+ * calls a human instead. Guessing a time here is the one outcome worth
1425
+ * avoiding: waking early burns a retry and changes nothing.
1426
+ */
1427
+ rateLimitRefusal(error) {
1428
+ const haystack = JSON.stringify(error).toLowerCase();
1429
+ const refused = haystack.includes('ratelimitreached') ||
1430
+ haystack.includes('rate_limit_reached') ||
1431
+ haystack.includes('usage_limit_reached') ||
1432
+ haystack.includes('usagelimitreached');
1433
+ if (!refused)
1434
+ return null;
1435
+ const fullest = this.rateLimitWindows.reduce((worst, window) => worst === null || window.usedPercent > worst.usedPercent ? window : worst, null);
1436
+ return { key: fullest?.key ?? 'other', resetsAt: fullest?.resetsAt ?? null };
1437
+ }
1354
1438
  onTurnCompleted(params) {
1355
1439
  const turn = asRecord(params['turn']);
1356
1440
  const status = str(turn['status']);
@@ -1366,10 +1450,22 @@ class CodexSession {
1366
1450
  return;
1367
1451
  if (status === 'failed') {
1368
1452
  const error = asRecord(turn['error']);
1453
+ // Ticket #258. Codex says «you were refused»; it does NOT have to say
1454
+ // when the refusal lifts, because `account/rateLimits/updated` has been
1455
+ // telling us that all along. So the error only has to be recognised, and
1456
+ // the clock comes from the last snapshot — which also means a change in
1457
+ // the error's wording costs us the auto-pause, never a wrong wake-up.
1458
+ const blocked = this.rateLimitRefusal(error);
1459
+ if (blocked)
1460
+ this.emitRateLimits(blocked);
1369
1461
  this.emit({
1370
1462
  type: 'turn_end',
1371
1463
  ok: false,
1372
1464
  errorMessage: maskString(str(error['message']) ?? 'The Codex turn failed').slice(0, 500),
1465
+ // Without this the session goes FAILED — terminal — and the pause armed
1466
+ // one line earlier would ring into a dead row and throw the person's
1467
+ // queued words away instead of sending them.
1468
+ ...(blocked ? { limitBlocked: true } : {}),
1373
1469
  });
1374
1470
  return;
1375
1471
  }
@@ -0,0 +1,40 @@
1
+ import type { AgentRateLimitWindow } from './types.js';
2
+ /**
3
+ * Plan-window normalisation shared by every adapter (#279, #258).
4
+ *
5
+ * It lives in its own file rather than inside one adapter because the two CLIs
6
+ * describe the same fact in two different vocabularies — Claude names its
7
+ * windows, Codex measures them in minutes — and the owner intends to add more
8
+ * providers. One place decides what a window is called, so a third adapter has
9
+ * a table to join rather than a convention to guess.
10
+ */
11
+ /** How long each named window is. The dashboard writes its label from this. */
12
+ export declare const RATE_WINDOW_MINUTES: Record<AgentRateLimitWindow['key'], number | null>;
13
+ /**
14
+ * A provider's own window name → ours.
15
+ *
16
+ * Claude has five names for the weekly window (`seven_day`, `seven_day_opus`,
17
+ * `seven_day_sonnet`, `seven_day_overage_included`, `seven_day_oauth_apps`) and
18
+ * they are all seven days; `startsWith` keeps a new suffix from silently
19
+ * becoming `other` on the next SDK release. Anything genuinely unknown stays
20
+ * `other` — a window we cannot name is still a window worth showing.
21
+ */
22
+ export declare function rateWindowKey(providerName: string): AgentRateLimitWindow['key'];
23
+ /**
24
+ * Minutes → our key, for providers that describe a window by its length.
25
+ *
26
+ * Exact matches only. «About five hours» is not a five-hour window, and a
27
+ * provider that reports 360 minutes means something we have no label for yet —
28
+ * `other` carries its length through and the dashboard says «6 часов» rather
29
+ * than mislabelling it.
30
+ */
31
+ export declare function rateWindowKeyFromMinutes(minutes: number | null): AgentRateLimitWindow['key'];
32
+ /**
33
+ * 0–100, always.
34
+ *
35
+ * A percentage out of range is not worth a thrown error — it is worth not
36
+ * being drawn as a 340%-full ring. NaN and infinities collapse to 0, which
37
+ * reads as «nothing spent» and is the safe end: it never invents a warning.
38
+ */
39
+ export declare function clampPercent(value: number): number;
40
+ //# sourceMappingURL=rate-limits.d.ts.map
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Plan-window normalisation shared by every adapter (#279, #258).
3
+ *
4
+ * It lives in its own file rather than inside one adapter because the two CLIs
5
+ * describe the same fact in two different vocabularies — Claude names its
6
+ * windows, Codex measures them in minutes — and the owner intends to add more
7
+ * providers. One place decides what a window is called, so a third adapter has
8
+ * a table to join rather than a convention to guess.
9
+ */
10
+ /** How long each named window is. The dashboard writes its label from this. */
11
+ export const RATE_WINDOW_MINUTES = {
12
+ five_hour: 300,
13
+ seven_day: 10_080,
14
+ other: null,
15
+ };
16
+ /**
17
+ * A provider's own window name → ours.
18
+ *
19
+ * Claude has five names for the weekly window (`seven_day`, `seven_day_opus`,
20
+ * `seven_day_sonnet`, `seven_day_overage_included`, `seven_day_oauth_apps`) and
21
+ * they are all seven days; `startsWith` keeps a new suffix from silently
22
+ * becoming `other` on the next SDK release. Anything genuinely unknown stays
23
+ * `other` — a window we cannot name is still a window worth showing.
24
+ */
25
+ export function rateWindowKey(providerName) {
26
+ if (providerName === 'five_hour')
27
+ return 'five_hour';
28
+ if (providerName.startsWith('seven_day'))
29
+ return 'seven_day';
30
+ return 'other';
31
+ }
32
+ /**
33
+ * Minutes → our key, for providers that describe a window by its length.
34
+ *
35
+ * Exact matches only. «About five hours» is not a five-hour window, and a
36
+ * provider that reports 360 minutes means something we have no label for yet —
37
+ * `other` carries its length through and the dashboard says «6 часов» rather
38
+ * than mislabelling it.
39
+ */
40
+ export function rateWindowKeyFromMinutes(minutes) {
41
+ if (minutes === RATE_WINDOW_MINUTES.five_hour)
42
+ return 'five_hour';
43
+ if (minutes === RATE_WINDOW_MINUTES.seven_day)
44
+ return 'seven_day';
45
+ return 'other';
46
+ }
47
+ /**
48
+ * 0–100, always.
49
+ *
50
+ * A percentage out of range is not worth a thrown error — it is worth not
51
+ * being drawn as a 340%-full ring. NaN and infinities collapse to 0, which
52
+ * reads as «nothing spent» and is the safe end: it never invents a warning.
53
+ */
54
+ export function clampPercent(value) {
55
+ if (!Number.isFinite(value))
56
+ return 0;
57
+ return Math.min(100, Math.max(0, value));
58
+ }
59
+ //# sourceMappingURL=rate-limits.js.map
@@ -135,6 +135,55 @@ export interface AgentTask {
135
135
  /** Present only with `agentProgressSummaries`: «Analyzing the auth module». */
136
136
  summary?: string;
137
137
  }
138
+ /**
139
+ * One plan window — «31% of the five hours is spent, it resets at 18:50».
140
+ *
141
+ * `key` is ours, not either CLI's. Claude names its windows, Codex describes
142
+ * them by length in minutes, and a third provider will do a third thing; the
143
+ * dashboard needs one vocabulary to write a label in. `other` is not a failure
144
+ * — it is a window we have no name for yet, and `windowMinutes` is what lets
145
+ * the dashboard still say something true about it («7 дней», «30 дней») rather
146
+ * than dropping it.
147
+ */
148
+ export interface AgentRateLimitWindow {
149
+ key: 'five_hour' | 'seven_day' | 'other';
150
+ /** Length of the window, when the provider states it. */
151
+ windowMinutes: number | null;
152
+ /** 0–100. What is SPENT, not what is left — both CLIs report it that way. */
153
+ usedPercent: number;
154
+ /** Absolute moment, ISO 8601. Null when the provider did not say. */
155
+ resetsAt: string | null;
156
+ }
157
+ /**
158
+ * The account's plan usage, as one snapshot (#279, #258).
159
+ *
160
+ * `available: false` is a real answer, not an error: an organization running on
161
+ * its own API key, Bedrock or Vertex has no plan windows at all, and the popup
162
+ * must say so rather than draw zeros.
163
+ */
164
+ export interface AgentRateLimits {
165
+ available: boolean;
166
+ /** `max`, `pro`, … exactly as the CLI names it. Null when unknown. */
167
+ planType: string | null;
168
+ /** When the snapshot was taken, on the RUNNER's clock, ISO 8601. */
169
+ measuredAt: string;
170
+ windows: AgentRateLimitWindow[];
171
+ /**
172
+ * Set only when the provider REFUSED a turn — ticket #258.
173
+ *
174
+ * The difference from a window at 100% is that this turn did not run: the
175
+ * words the person sent were not answered, and something has to send them
176
+ * again. A full window is a warning; this is an event.
177
+ *
178
+ * `resetsAt` may be null when the provider refuses without saying when it
179
+ * will stop — the API then declines to schedule a wake-up rather than guess a
180
+ * time, because waking early burns the retry for nothing.
181
+ */
182
+ blocked: {
183
+ key: AgentRateLimitWindow['key'];
184
+ resetsAt: string | null;
185
+ } | null;
186
+ }
138
187
  /** Everything the dashboard needs to render agent controls, live from the agent. */
139
188
  export interface AgentCapabilities {
140
189
  models: ModelOption[];
@@ -353,6 +402,24 @@ export type AgentEvent = {
353
402
  usedTokens: number;
354
403
  maxTokens: number;
355
404
  }
405
+ /**
406
+ * How much of the SUBSCRIPTION is left — tickets #279 and #258.
407
+ *
408
+ * Deliberately not shaped like either CLI. Claude names its windows
409
+ * (`five_hour`, `seven_day`), Codex describes them by length
410
+ * (`window_minutes: 300`), and the owner intends to add further providers
411
+ * (Gemini) — so the wire form is «a list of windows, each with a percentage
412
+ * and a reset moment», and every adapter normalises into it. A new provider
413
+ * adds a branch in its own adapter and nothing else.
414
+ *
415
+ * NOT the same thing as `context_usage`: that is how full the model's
416
+ * context window is for THIS session, this is how much of the account's plan
417
+ * is spent across every session on the machine.
418
+ */
419
+ | {
420
+ type: 'rate_limits';
421
+ limits: AgentRateLimits;
422
+ }
356
423
  /**
357
424
  * Everything running beside the conversation, right now (ticket #113).
358
425
  *
@@ -387,6 +454,17 @@ export type AgentEvent = {
387
454
  * as work the agent completed.
388
455
  */
389
456
  aborted?: boolean;
457
+ /**
458
+ * The plan refused this turn — it never ran (#258).
459
+ *
460
+ * Reported so the supervisor does NOT file the session as FAILED. That
461
+ * status is terminal, and a terminal session cannot be woken: the pause
462
+ * we just armed would ring into a dead row, cancel the words the person
463
+ * had queued and log «the session ended before its pause was up». The
464
+ * turn genuinely did not succeed, but the session is fine — it is waiting
465
+ * for a window to open, which is what «waiting» means.
466
+ */
467
+ limitBlocked?: boolean;
390
468
  } | {
391
469
  type: 'error';
392
470
  message: string;
@@ -1355,6 +1355,17 @@ export class Supervisor {
1355
1355
  activeMs: Supervisor.spentMs(running),
1356
1356
  });
1357
1357
  }
1358
+ else if (event.limitBlocked) {
1359
+ // #258. The plan refused this turn — it never ran, so the session has
1360
+ // not failed at anything: it is waiting for a window to open, and the
1361
+ // API has just armed a clock to wake it. FAILED is terminal, and a
1362
+ // terminal session cannot be woken — the pause would ring into a dead
1363
+ // row and cancel the words the person queued behind it.
1364
+ this.reportStatus(descriptor.id, descriptor.kind === 'CHAT' ? 'WAITING_INPUT' : 'REVIEW', {
1365
+ costUsd: running.costUsd,
1366
+ activeMs: Supervisor.spentMs(running),
1367
+ });
1368
+ }
1358
1369
  else {
1359
1370
  this.reportStatus(descriptor.id, 'FAILED', {
1360
1371
  costUsd: running.costUsd,
@@ -1561,6 +1572,13 @@ export class Supervisor {
1561
1572
  maxTokens: event.maxTokens,
1562
1573
  });
1563
1574
  return;
1575
+ // #279. A LEVEL signal like `agent_tasks`: every frame carries the whole
1576
+ // snapshot, so the API stores what arrived rather than merging, and a
1577
+ // dropped frame costs freshness, never correctness. Account-wide, not
1578
+ // session-wide — the API files it under the SERVER, not this session.
1579
+ case 'rate_limits':
1580
+ this.sendEvent(running, 'rate_limits', { ...event.limits });
1581
+ return;
1564
1582
  case 'agent_tasks':
1565
1583
  // Ticket #113. A LEVEL signal: every frame carries the whole live set,
1566
1584
  // so the dashboard replaces rather than reconciles and a dropped frame
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.40.0";
1
+ export declare const RUNNER_VERSION = "0.41.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Kept in sync with package.json by the release script (manual for now).
2
- export const RUNNER_VERSION = '0.40.0';
2
+ export const RUNNER_VERSION = '0.41.0';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.40.0",
3
+ "version": "0.41.0",
4
4
  "description": "DevBridge dev runner — connects a dev server to DevBridge and runs agent sessions (Claude Code / Codex)",
5
5
  "homepage": "https://bridge4.dev",
6
6
  "license": "MIT",