@bridge4dev/runner 0.39.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
@@ -287,6 +288,15 @@ class ClaudeSession {
287
288
  mcpFallbackNotice = null;
288
289
  /** Guards against overlapping capability probes. */
289
290
  capabilitiesInFlight = false;
291
+ /**
292
+ * Has the first main-loop answer of this process already asked for the
293
+ * context window? (ticket #223)
294
+ *
295
+ * `q` is `readonly` — one adapter per CLI process — so this never needs
296
+ * resetting: a restarted process is a new instance with the flag back to
297
+ * false, which is exactly the moment the meter needs a fresh number.
298
+ */
299
+ contextProbedAfterFirstReply = false;
290
300
  mode;
291
301
  /**
292
302
  * A launch-time mode this workspace does not allow, remembered so the feed
@@ -559,6 +569,13 @@ class ClaudeSession {
559
569
  // message would otherwise show no model list at all — while the control
560
570
  // requests themselves work as soon as the CLI is up.
561
571
  this.refreshCapabilities();
572
+ // Same reasoning for the context meter (ticket #223). It used to be asked
573
+ // for only at the END of a turn, so a session that had not finished its
574
+ // first one had no numbers at all and the gauge did not exist — worst in a
575
+ // session that parked a question, which can sit there for hours. This probe
576
+ // costs one control round-trip and gives the meter its denominator (the
577
+ // model's window) from the moment the process is up.
578
+ this.refreshContextUsage();
562
579
  }
563
580
  /**
564
581
  * Write this session's MCP config to a 0600 file and return its path, or null
@@ -623,10 +640,14 @@ class ClaudeSession {
623
640
  this.output.push(maskSecrets(event));
624
641
  }
625
642
  /**
626
- * How full the context window is, after a finished turn. Fire-and-forget with
627
- * a timeout on purpose: this is one more control round-trip on a channel that
628
- * can hang, and it must never be able to stall the event loop that carries the
629
- * conversation.
643
+ * How full the context window is. Fire-and-forget with a timeout on purpose:
644
+ * this is one more control round-trip on a channel that can hang, and it must
645
+ * never be able to stall the event loop that carries the conversation.
646
+ *
647
+ * Called from four places, each answering a different gap: process start and
648
+ * first main-loop reply (ticket #223 — otherwise a session shows no gauge at
649
+ * all until its first turn ends), model change (the window's size differs 5x
650
+ * between 200k and 1M), and end of turn.
630
651
  */
631
652
  refreshContextUsage() {
632
653
  if (this.stopped)
@@ -662,6 +683,93 @@ class ClaudeSession {
662
683
  })
663
684
  .catch((error) => log.warn('claude: context usage probe failed', { error: String(error) }));
664
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
+ }
665
773
  /** Fire-and-forget capability refresh — never throws into the caller. */
666
774
  refreshCapabilities() {
667
775
  if (this.capabilitiesInFlight || this.stopped)
@@ -1772,6 +1880,11 @@ class ClaudeSession {
1772
1880
  // The live model can differ from what we asked for (alias
1773
1881
  // resolution, fallback) — refresh the pickers when it does.
1774
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);
1775
1888
  }
1776
1889
  else if (msg.subtype === 'status') {
1777
1890
  const status = msg.status;
@@ -1799,6 +1912,14 @@ class ClaudeSession {
1799
1912
  this.emit({ type: 'notice', level: 'info', text: 'Context cleared (/clear)' });
1800
1913
  break;
1801
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
+ }
1802
1923
  case 'assistant': {
1803
1924
  // Ticket #186. A subagent's own prose is NOT this session's answer.
1804
1925
  //
@@ -1845,6 +1966,17 @@ class ClaudeSession {
1845
1966
  });
1846
1967
  }
1847
1968
  }
1969
+ // Ticket #223. The startup probe fires before the CLI has read
1970
+ // anything, so its number is the empty window; this one lands after
1971
+ // the agent has actually said something and is the first figure
1972
+ // worth showing. Once per process, and only for the main loop: it is
1973
+ // a control round-trip on a channel that can hang, subagent chatter
1974
+ // is not this session's context, and the end-of-turn probe covers
1975
+ // everything after.
1976
+ if (!fromSubagent && !this.contextProbedAfterFirstReply) {
1977
+ this.contextProbedAfterFirstReply = true;
1978
+ this.refreshContextUsage();
1979
+ }
1848
1980
  break;
1849
1981
  }
1850
1982
  case 'user': {
@@ -1898,10 +2030,24 @@ class ClaudeSession {
1898
2030
  // A turn the user stopped is not a failed turn. Reporting it as
1899
2031
  // one moved the session to FAILED, which is terminal — pressing
1900
2032
  // Stop cost people the session they meant to keep.
1901
- 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
+ });
1902
2043
  }
1903
2044
  else {
1904
- 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
+ });
1905
2051
  }
1906
2052
  break;
1907
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;
@@ -18,9 +18,32 @@ const execFileAsync = promisify(execFile);
18
18
  */
19
19
  /** Directory inside the worktree; also the line written to info/exclude. */
20
20
  export const ATTACHMENT_DIR = '.devbridge/attachments';
21
- /** Hard ceiling per file; the API allows 10 MB for images and 25 MB otherwise. */
22
- const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024;
23
- const DOWNLOAD_TIMEOUT_MS = 60_000;
21
+ /**
22
+ * Hard ceiling per file; the API allows 10 MB for images and 50 MB otherwise.
23
+ *
24
+ * Mirror of `MAX_DOCUMENT_ATTACHMENT_SIZE_BYTES` in `@devbridge/shared` plus a
25
+ * megabyte of slack — the runner cannot import that package (see
26
+ * `recipe-schema.ts`: the published tarball would not resolve it). The slack is
27
+ * what keeps the two halves from disagreeing on a rounding: the API refuses
28
+ * first, with an error the person actually sees, and this only catches bytes
29
+ * that no upload path should have produced.
30
+ *
31
+ * Raise the API side and this one in the SAME runner release. A ceiling that
32
+ * moves only in DevBridge lets a file upload and then vanish: the refusal below
33
+ * is a warning line in the session, not something the uploader is shown.
34
+ */
35
+ const MAX_ATTACHMENT_BYTES = 51 * 1024 * 1024;
36
+ /**
37
+ * A minute was plenty while the ceiling was 26 MB; at 50 MB it is a coin flip.
38
+ *
39
+ * The whole file is buffered by the API and pushed through nginx in one
40
+ * response, so this timeout covers the entire transfer, not idle time between
41
+ * packets. 60 s for 50 MB demands ~7 Mbit/s sustained end to end — ordinary for
42
+ * two VPSes, not ordinary for a dev server on a home uplink or behind a
43
+ * throttled proxy. Three minutes covers ~2.3 Mbit/s, and the cost of being
44
+ * generous is nil: a stalled download fails the same way, just later.
45
+ */
46
+ const DOWNLOAD_TIMEOUT_MS = 180_000;
24
47
  /**
25
48
  * A file name that is safe to write and unambiguous to read.
26
49
  *
@@ -153,9 +176,16 @@ export async function saveAttachments(input) {
153
176
  pruneAttachmentDir(dir);
154
177
  return { saved, failed };
155
178
  }
156
- /** Keep at most this much history in one worktree's attachment folder. */
179
+ /**
180
+ * Keep at most this much history in one worktree's attachment folder.
181
+ *
182
+ * Sized in files, not in bytes: 512 MB was twenty attachments at the old 25 MB
183
+ * ceiling and would be ten at the new one — an afternoon of work, after which
184
+ * the folder starts evicting documents a running session may still be told to
185
+ * open. A gigabyte restores the same twenty-file depth.
186
+ */
157
187
  const ATTACHMENT_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
158
- const ATTACHMENT_DIR_BUDGET_BYTES = 512 * 1024 * 1024;
188
+ const ATTACHMENT_DIR_BUDGET_BYTES = 1024 * 1024 * 1024;
159
189
  /**
160
190
  * Delete old attachments from a session worktree.
161
191
  *
@@ -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.39.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.39.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.39.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",