@bridge4dev/runner 0.47.0 → 0.48.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.
@@ -1157,10 +1157,13 @@ class CodexSession {
1157
1157
  }
1158
1158
  return;
1159
1159
  }
1160
- // #279. Removed from `OPT_OUT_NOTIFICATIONS` on purpose: it is not a
1161
- // delta stream — Codex sends it when a turn changes the account's usage,
1162
- // which is orders of magnitude rarer than the text deltas that list
1163
- // defends against.
1160
+ // #279. Kept out of `OPT_OUT_NOTIFICATIONS` on purpose, but NOT because
1161
+ // it is rare: Codex sends it after every model request, as often as
1162
+ // `thread/tokenUsage/updated` (production count 56 591 against 57 008,
1163
+ // 05.09.2026 – #366). What keeps it off the wire is the supervisor's
1164
+ // level gate, which forwards a snapshot only when it changed, carries a
1165
+ // refusal (`blocked`), or is older than the resend floor. Opting the
1166
+ // notification out here instead would silence the refusal too.
1164
1167
  case 'account/rateLimits/updated': {
1165
1168
  this.onRateLimits(params);
1166
1169
  return;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Mirror of the `#region level-events-mirror` block in
3
+ * `packages/shared/src/constants/realtime.ts` – the DevBridge side is the
4
+ * source of truth, exactly like `agent-registry.ts` mirrors the agent registry
5
+ * and `protocol.ts` mirrors the API's wire types.
6
+ *
7
+ * Copied rather than imported on purpose: this package is published to npm on
8
+ * its own and installed by users who have no DevBridge workspace, so a
9
+ * `@devbridge/shared` import would make the published tarball unresolvable.
10
+ *
11
+ * CHECKED: `levels.test.ts` reads both files and compares the region between
12
+ * the markers character by character. Edit the shared file first, then paste
13
+ * the region here – nothing but the region, and nothing of the region left out.
14
+ */
15
+ /**
16
+ * Level signals on the dev-session stream – frames that carry a whole current
17
+ * value rather than a step of the conversation (#366).
18
+ *
19
+ * `agent_tasks` (#113), `context_usage` and `rate_limits` (#279) are LEVELS:
20
+ * every frame replaces the previous one, so a dropped frame costs freshness and
21
+ * never correctness. In production they were 61 % of every Codex session's
22
+ * rows (57 008 + 56 591 of ~185 000, 05.09.2026) while drawing not one line in
23
+ * the transcript – and each one re-rendered the whole page. So they are treated
24
+ * differently at every hop, and this block is the one place that says how:
25
+ *
26
+ * - the runner sends one only when the value moved (the thresholds below);
27
+ * - the API publishes it as a META frame (no `id:`) and never stores a row;
28
+ * - the dashboard keeps it beside the feed, never in it.
29
+ *
30
+ * Mirrored verbatim into `packages/runner/src/levels.ts` – the runner cannot
31
+ * import this package (it is published to npm on its own). `levels.test.ts`
32
+ * compares the two regions character by character.
33
+ */
34
+ export declare const LEVEL_EVENT_TYPES: readonly ["agent_tasks", "context_usage", "rate_limits"];
35
+ /**
36
+ * A context-meter move smaller than BOTH of these is not worth a frame. The
37
+ * same pair gates the API's mirror onto the session row, so the ring a reload
38
+ * draws from the row and the ring the live frame draws agree to the percent.
39
+ */
40
+ export declare const CONTEXT_USAGE_MIN_DELTA_TOKENS = 2000;
41
+ export declare const CONTEXT_USAGE_MIN_DELTA_RATIO = 0.01;
42
+ /**
43
+ * An unchanged plan-usage snapshot is re-sent no more often than this. It is
44
+ * re-sent at all because the limits panel dates its figure by ARRIVAL
45
+ * («updated 12 min ago»): silence would read as staleness. Three minutes is
46
+ * the interval the Claude adapter already probes `/usage` at.
47
+ */
48
+ export declare const RATE_LIMITS_RESEND_INTERVAL_MS: number;
49
+ //# sourceMappingURL=levels.d.ts.map
package/dist/levels.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Mirror of the `#region level-events-mirror` block in
3
+ * `packages/shared/src/constants/realtime.ts` – the DevBridge side is the
4
+ * source of truth, exactly like `agent-registry.ts` mirrors the agent registry
5
+ * and `protocol.ts` mirrors the API's wire types.
6
+ *
7
+ * Copied rather than imported on purpose: this package is published to npm on
8
+ * its own and installed by users who have no DevBridge workspace, so a
9
+ * `@devbridge/shared` import would make the published tarball unresolvable.
10
+ *
11
+ * CHECKED: `levels.test.ts` reads both files and compares the region between
12
+ * the markers character by character. Edit the shared file first, then paste
13
+ * the region here – nothing but the region, and nothing of the region left out.
14
+ */
15
+ // #region level-events-mirror
16
+ /**
17
+ * Level signals on the dev-session stream – frames that carry a whole current
18
+ * value rather than a step of the conversation (#366).
19
+ *
20
+ * `agent_tasks` (#113), `context_usage` and `rate_limits` (#279) are LEVELS:
21
+ * every frame replaces the previous one, so a dropped frame costs freshness and
22
+ * never correctness. In production they were 61 % of every Codex session's
23
+ * rows (57 008 + 56 591 of ~185 000, 05.09.2026) while drawing not one line in
24
+ * the transcript – and each one re-rendered the whole page. So they are treated
25
+ * differently at every hop, and this block is the one place that says how:
26
+ *
27
+ * - the runner sends one only when the value moved (the thresholds below);
28
+ * - the API publishes it as a META frame (no `id:`) and never stores a row;
29
+ * - the dashboard keeps it beside the feed, never in it.
30
+ *
31
+ * Mirrored verbatim into `packages/runner/src/levels.ts` – the runner cannot
32
+ * import this package (it is published to npm on its own). `levels.test.ts`
33
+ * compares the two regions character by character.
34
+ */
35
+ export const LEVEL_EVENT_TYPES = ['agent_tasks', 'context_usage', 'rate_limits'];
36
+ /**
37
+ * A context-meter move smaller than BOTH of these is not worth a frame. The
38
+ * same pair gates the API's mirror onto the session row, so the ring a reload
39
+ * draws from the row and the ring the live frame draws agree to the percent.
40
+ */
41
+ export const CONTEXT_USAGE_MIN_DELTA_TOKENS = 2_000;
42
+ export const CONTEXT_USAGE_MIN_DELTA_RATIO = 0.01;
43
+ /**
44
+ * An unchanged plan-usage snapshot is re-sent no more often than this. It is
45
+ * re-sent at all because the limits panel dates its figure by ARRIVAL
46
+ * («updated 12 min ago»): silence would read as staleness. Three minutes is
47
+ * the interval the Claude adapter already probes `/usage` at.
48
+ */
49
+ export const RATE_LIMITS_RESEND_INTERVAL_MS = 3 * 60 * 1000;
50
+ // #endregion level-events-mirror
51
+ //# sourceMappingURL=levels.js.map
@@ -109,6 +109,13 @@ export interface SupervisorOptions {
109
109
  * firing over a working agent (QA-2026-08-16 M-4). Never set in production.
110
110
  */
111
111
  emptyTurnSettleMs?: number;
112
+ /**
113
+ * How long an unchanged plan-usage snapshot is held before it is re-sent
114
+ * (#366) – a test seam over `RATE_LIMITS_RESEND_INTERVAL_MS`, for the same
115
+ * reason `emptyTurnSettleMs` exists: the real floor is three minutes and this
116
+ * suite runs on real timers. Never set in production.
117
+ */
118
+ rateLimitsResendMs?: number;
112
119
  }
113
120
  export declare class Supervisor {
114
121
  private readonly ws;
@@ -136,6 +143,7 @@ export declare class Supervisor {
136
143
  private static readonly EMPTY_TURN_SETTLE_MS;
137
144
  /** The window actually used — the constant, or a test's own shorter one. */
138
145
  private readonly emptyTurnSettleMs;
146
+ private readonly rateLimitsResendMs;
139
147
  /** A finished session's journal is kept this long for a late reconnect. */
140
148
  private static readonly JOURNAL_TTL_MS;
141
149
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -525,6 +533,29 @@ export declare class Supervisor {
525
533
  * its timer runs out, and it must end in exactly the way it would have
526
534
  * ended immediately. A copy would be two behaviours one edit apart.
527
535
  */
536
+ /**
537
+ * The level gate for the context meter (#366).
538
+ *
539
+ * While a turn is open, a frame goes out only when the meter moved by at
540
+ * least one of the shared thresholds or the window itself changed; the
541
+ * newest held-back value is flushed right before `turn_end`, so the ring at
542
+ * rest is exact. Outside a turn every frame passes – there are only a handful
543
+ * (process start, a model change, and the Claude adapter's own measurement,
544
+ * which resolves AFTER `turn_end`), and each one is news.
545
+ */
546
+ private forwardContextUsage;
547
+ /** Send the value the gate was holding, if any – strictly before `turn_end`. */
548
+ private flushHeldContextUsage;
549
+ /**
550
+ * The level gate for plan usage (#366).
551
+ *
552
+ * A snapshot goes out when it is the first, when anything but `measuredAt`
553
+ * changed, when it carries a refusal (#258 – always, even twice in a row), or
554
+ * when the one last sent is older than the resend floor. The fingerprint is
555
+ * the whole payload minus the clock, so a field an adapter adds tomorrow is
556
+ * part of it without anybody remembering to list it here.
557
+ */
558
+ private forwardRateLimits;
528
559
  private completeTurn;
529
560
  /**
530
561
  * Move the session to the status a finished turn leaves it in.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { log } from './log.js';
4
+ import { CONTEXT_USAGE_MIN_DELTA_RATIO, CONTEXT_USAGE_MIN_DELTA_TOKENS, RATE_LIMITS_RESEND_INTERVAL_MS, } from './levels.js';
4
5
  import { claimAutoResume, clearAutoResume, pruneAutoResume } from './auto-resume.js';
5
6
  import { classifyFailure, isRepeatOfSameFailure, MAX_RETRIES_PER_SESSION, retryDelayMs, } from './adapters/error-policy.js';
6
7
  import { evaluateRecipeCommand, maskSecrets, maskString } from './policy.js';
@@ -58,6 +59,16 @@ function gitPolicyOf(descriptor) {
58
59
  : {}),
59
60
  };
60
61
  }
62
+ function freshLevels() {
63
+ return {
64
+ contextSent: null,
65
+ contextHeld: null,
66
+ contextPassNext: false,
67
+ limitsSent: null,
68
+ sent: 0,
69
+ dropped: 0,
70
+ };
71
+ }
61
72
  const LAUNCH_REFUSED = { ok: false, reason: 'refused' };
62
73
  export class Supervisor {
63
74
  ws;
@@ -85,6 +96,7 @@ export class Supervisor {
85
96
  static EMPTY_TURN_SETTLE_MS = 25_000;
86
97
  /** The window actually used — the constant, or a test's own shorter one. */
87
98
  emptyTurnSettleMs;
99
+ rateLimitsResendMs;
88
100
  /** A finished session's journal is kept this long for a late reconnect. */
89
101
  static JOURNAL_TTL_MS = 72 * 3_600_000;
90
102
  /** Backstop: events the API will never accept must not pile up forever. */
@@ -135,6 +147,7 @@ export class Supervisor {
135
147
  this.opts = opts;
136
148
  this.journals = opts.journals ?? new JournalStore();
137
149
  this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
150
+ this.rateLimitsResendMs = opts.rateLimitsResendMs ?? RATE_LIMITS_RESEND_INTERVAL_MS;
138
151
  this.verify = new VerifyRunner({
139
152
  enabled: opts.verifyEnabled !== false,
140
153
  onReport: (report) => {
@@ -743,6 +756,7 @@ export class Supervisor {
743
756
  lastReported: descriptor.status,
744
757
  costUsd: descriptor.costUsd,
745
758
  costBaseUsd: descriptor.costUsd,
759
+ levels: freshLevels(),
746
760
  stopRequested: false,
747
761
  parkRequested: false,
748
762
  pendingMessages: [],
@@ -1697,12 +1711,101 @@ export class Supervisor {
1697
1711
  * its timer runs out, and it must end in exactly the way it would have
1698
1712
  * ended immediately. A copy would be two behaviours one edit apart.
1699
1713
  */
1714
+ /**
1715
+ * The level gate for the context meter (#366).
1716
+ *
1717
+ * While a turn is open, a frame goes out only when the meter moved by at
1718
+ * least one of the shared thresholds or the window itself changed; the
1719
+ * newest held-back value is flushed right before `turn_end`, so the ring at
1720
+ * rest is exact. Outside a turn every frame passes – there are only a handful
1721
+ * (process start, a model change, and the Claude adapter's own measurement,
1722
+ * which resolves AFTER `turn_end`), and each one is news.
1723
+ */
1724
+ forwardContextUsage(running, usedTokens, maxTokens) {
1725
+ const levels = running.levels;
1726
+ const frame = { usedTokens, maxTokens };
1727
+ const last = levels.contextSent;
1728
+ const turnOpen = running.lastReported === 'RUNNING';
1729
+ const moved = last === null ? Number.POSITIVE_INFINITY : Math.abs(usedTokens - last.usedTokens);
1730
+ const pass = last === null ||
1731
+ last.maxTokens !== maxTokens ||
1732
+ levels.contextPassNext ||
1733
+ !turnOpen ||
1734
+ moved >= CONTEXT_USAGE_MIN_DELTA_TOKENS ||
1735
+ moved / maxTokens >= CONTEXT_USAGE_MIN_DELTA_RATIO;
1736
+ // Only the newest value is worth keeping; whatever it replaces is gone.
1737
+ if (levels.contextHeld !== null)
1738
+ levels.dropped += 1;
1739
+ if (!pass) {
1740
+ levels.contextHeld = frame;
1741
+ return;
1742
+ }
1743
+ levels.contextHeld = null;
1744
+ levels.contextPassNext = false;
1745
+ levels.contextSent = frame;
1746
+ levels.sent += 1;
1747
+ this.sendEvent(running, 'context_usage', frame);
1748
+ }
1749
+ /** Send the value the gate was holding, if any – strictly before `turn_end`. */
1750
+ flushHeldContextUsage(running) {
1751
+ const levels = running.levels;
1752
+ const held = levels.contextHeld;
1753
+ if (held === null)
1754
+ return;
1755
+ levels.contextHeld = null;
1756
+ // A held value identical to the last one sent is not news – flushing it
1757
+ // would add a frame to every turn and give back half the saving.
1758
+ const sent = levels.contextSent;
1759
+ if (sent !== null && sent.usedTokens === held.usedTokens && sent.maxTokens === held.maxTokens) {
1760
+ return;
1761
+ }
1762
+ levels.contextSent = held;
1763
+ levels.sent += 1;
1764
+ this.sendEvent(running, 'context_usage', held);
1765
+ }
1766
+ /**
1767
+ * The level gate for plan usage (#366).
1768
+ *
1769
+ * A snapshot goes out when it is the first, when anything but `measuredAt`
1770
+ * changed, when it carries a refusal (#258 – always, even twice in a row), or
1771
+ * when the one last sent is older than the resend floor. The fingerprint is
1772
+ * the whole payload minus the clock, so a field an adapter adds tomorrow is
1773
+ * part of it without anybody remembering to list it here.
1774
+ */
1775
+ forwardRateLimits(running, limits) {
1776
+ const levels = running.levels;
1777
+ const print = JSON.stringify({ ...limits, measuredAt: undefined });
1778
+ const now = Date.now();
1779
+ const last = levels.limitsSent;
1780
+ const pass = last === null ||
1781
+ Boolean(limits.blocked) ||
1782
+ last.print !== print ||
1783
+ now - last.at >= this.rateLimitsResendMs;
1784
+ if (!pass) {
1785
+ levels.dropped += 1;
1786
+ return;
1787
+ }
1788
+ levels.limitsSent = { print, at: now };
1789
+ levels.sent += 1;
1790
+ this.sendEvent(running, 'rate_limits', { ...limits });
1791
+ }
1700
1792
  completeTurn(running, descriptor, event) {
1793
+ // #366: the ring at rest must be exact – the value the gate held back
1794
+ // during the turn goes out first, so it is older than `turn_end` by seq.
1795
+ this.flushHeldContextUsage(running);
1701
1796
  this.sendEvent(running, 'turn_end', {
1702
1797
  ok: event.ok,
1703
1798
  errorMessage: event.errorMessage,
1704
1799
  ...(event.aborted ? { aborted: true } : {}),
1705
1800
  });
1801
+ // …and whatever the adapter measures once the turn is over (Claude does,
1802
+ // asynchronously) is news, not a step of the same turn.
1803
+ running.levels.contextPassNext = true;
1804
+ log.info('supervisor: levels', {
1805
+ sessionId: descriptor.id,
1806
+ sent: running.levels.sent,
1807
+ dropped: running.levels.dropped,
1808
+ });
1706
1809
  // The session is already on its way out with a status that MEANS
1707
1810
  // something — a spent budget, a Stop, a teardown. A turn ending inside
1708
1811
  // that window is a consequence of it, and letting the line below
@@ -2221,17 +2324,17 @@ export class Supervisor {
2221
2324
  });
2222
2325
  return;
2223
2326
  case 'context_usage':
2224
- this.sendEvent(running, 'context_usage', {
2225
- usedTokens: event.usedTokens,
2226
- maxTokens: event.maxTokens,
2227
- });
2327
+ // #366. A LEVEL: only a value that moved is worth a frame. The gate
2328
+ // decides before `sendEvent`, so a dropped frame never spends a seq.
2329
+ this.forwardContextUsage(running, event.usedTokens, event.maxTokens);
2228
2330
  return;
2229
2331
  // #279. A LEVEL signal like `agent_tasks`: every frame carries the whole
2230
- // snapshot, so the API stores what arrived rather than merging, and a
2332
+ // snapshot, so the API keeps what arrived rather than merging, and a
2231
2333
  // dropped frame costs freshness, never correctness. Account-wide, not
2232
2334
  // session-wide — the API files it under the SERVER, not this session.
2335
+ // #366: gated the same way as the context meter, see `forwardRateLimits`.
2233
2336
  case 'rate_limits':
2234
- this.sendEvent(running, 'rate_limits', { ...event.limits });
2337
+ this.forwardRateLimits(running, event.limits);
2235
2338
  return;
2236
2339
  case 'agent_tasks': {
2237
2340
  // Ticket #113. A LEVEL signal: every frame carries the whole live set,
@@ -3444,6 +3547,7 @@ export class Supervisor {
3444
3547
  lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
3445
3548
  costUsd: descriptor.costUsd,
3446
3549
  costBaseUsd: descriptor.costUsd,
3550
+ levels: freshLevels(),
3447
3551
  stopRequested: false,
3448
3552
  parkRequested: false,
3449
3553
  pendingMessages: [],
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.47.0";
1
+ export declare const RUNNER_VERSION = "0.48.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.47.0';
2
+ export const RUNNER_VERSION = '0.48.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.47.0",
3
+ "version": "0.48.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",