@bridge4dev/runner 0.47.0 → 0.49.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.
@@ -2012,12 +2012,18 @@ class ClaudeSession {
2012
2012
  this.aborting = true;
2013
2013
  try {
2014
2014
  await this.q.interrupt();
2015
+ return 'accepted';
2015
2016
  }
2016
2017
  catch (error) {
2017
2018
  // The abort never reached the SDK, so any failure that arrives now is the
2018
2019
  // agent's own and must be reported as one.
2019
2020
  this.aborting = false;
2020
2021
  log.warn('claude: interrupt failed', { error: String(error) });
2022
+ // #373: answered rather than swallowed. `stopped` is the honest reading of
2023
+ // «we closed this query ourselves» — the SDK rejects every outstanding
2024
+ // control request with «Query closed before response received» on close,
2025
+ // and calling that a refusal would turn our own park into a failure.
2026
+ return this.stopped ? 'idle' : 'refused';
2021
2027
  }
2022
2028
  }
2023
2029
  conversationAnchor() {
@@ -2281,6 +2287,19 @@ class ClaudeSession {
2281
2287
  const aborted = this.aborting;
2282
2288
  this.aborting = false;
2283
2289
  const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
2290
+ // #373, plan stage D. What the incident could not answer: the second
2291
+ // result's origin was never recorded, so «is this the same turn
2292
+ // twice or a turn the CLI started by itself» had no evidence either
2293
+ // way. An allowlist of shapes the SDK marks optional, read through
2294
+ // type guards so a build that has none of them logs none of them —
2295
+ // no message text, no tool input, no environment, nothing to mask.
2296
+ log.info('claude: turn result', {
2297
+ sessionId: this.spec.sessionId,
2298
+ subtype: msg.subtype,
2299
+ aborted,
2300
+ produced: this.turnProduced,
2301
+ ...optionalDiagnostics(msg),
2302
+ });
2284
2303
  if (failure && isRewindError(failure)) {
2285
2304
  // Not a failed turn — a refused resume. The CLI answers a bad
2286
2305
  // `resumeSessionAt` with exactly this and nothing else (no
@@ -2351,6 +2370,12 @@ class ClaudeSession {
2351
2370
  type: 'error',
2352
2371
  message: classifyRunError(message),
2353
2372
  ...(code ? { code } : {}),
2373
+ // #373: this catch is the CLI process falling over under the read loop —
2374
+ // the only place in this adapter where that is what happened. Our own
2375
+ // `q.close()` does NOT come through here (the SDK ends the input stream
2376
+ // and the iteration finishes cleanly), so the flag stays an honest
2377
+ // answer to «did the process die on its own».
2378
+ processGone: true,
2354
2379
  });
2355
2380
  }
2356
2381
  finally {
@@ -2367,6 +2392,41 @@ class ClaudeSession {
2367
2392
  }
2368
2393
  }
2369
2394
  }
2395
+ /**
2396
+ * The optional shapes a `result` may carry, and nothing else (#373).
2397
+ *
2398
+ * An allowlist rather than «log the message»: an SDK message holds the whole
2399
+ * turn, and a runner log is not a place for a person's words, a tool's input or
2400
+ * an environment. Each field is read through a type guard and simply absent when
2401
+ * the build does not have it — no private SDK methods, and no minimum CLI
2402
+ * version required to read an optional field.
2403
+ *
2404
+ * - `uuid` names the RESULT, not the turn. Two results of one stop
2405
+ * have two of these, which is why it is evidence and not an
2406
+ * identifier to key anything on.
2407
+ * - `origin.kind` `task-notification` is a turn a background subagent woke
2408
+ * from inside the CLI — the one thing that tells a genuinely
2409
+ * new internal turn from a duplicate ending.
2410
+ * - `terminal_reason` the CLI's own word for why it stopped.
2411
+ */
2412
+ function optionalDiagnostics(msg) {
2413
+ if (!msg || typeof msg !== 'object')
2414
+ return {};
2415
+ const record = msg;
2416
+ const out = {};
2417
+ if (typeof record['uuid'] === 'string')
2418
+ out['resultUuid'] = record['uuid'];
2419
+ if (typeof record['terminal_reason'] === 'string') {
2420
+ out['terminalReason'] = record['terminal_reason'];
2421
+ }
2422
+ const origin = record['origin'];
2423
+ if (origin && typeof origin === 'object') {
2424
+ const kind = origin['kind'];
2425
+ if (typeof kind === 'string')
2426
+ out['originKind'] = kind;
2427
+ }
2428
+ return out;
2429
+ }
2370
2430
  // Recursive: MultiEdit-style inputs nest long strings inside arrays — a
2371
2431
  // shallow pass let them blow past the API's 128KB event cap (QA-96 F2).
2372
2432
  function truncateDeep(value, limit) {
@@ -7,6 +7,19 @@ export declare class RpcError extends Error {
7
7
  readonly method: string;
8
8
  constructor(code: number, message: string, method: string);
9
9
  }
10
+ /**
11
+ * The far end never answered in time (#370).
12
+ *
13
+ * A class of its own rather than a number the caller has to recognise: opening
14
+ * a conversation has to tell «Codex refused» from «Codex was too slow», and the
15
+ * only other way to ask was the sentence in `message`. That sentence is prose,
16
+ * and prose is exactly what turned a slow `thread/resume` into a dead session —
17
+ * `isMissingRollout` matched «no rollout found», not «timed out after 60000ms»,
18
+ * so none of the recovery already written for a refusal ever ran.
19
+ */
20
+ export declare class RpcTimeoutError extends RpcError {
21
+ constructor(method: string, timeoutMs: number);
22
+ }
10
23
  export interface ServerRequest {
11
24
  id: number | string;
12
25
  method: string;
@@ -10,6 +10,22 @@ export class RpcError extends Error {
10
10
  this.name = 'RpcError';
11
11
  }
12
12
  }
13
+ /**
14
+ * The far end never answered in time (#370).
15
+ *
16
+ * A class of its own rather than a number the caller has to recognise: opening
17
+ * a conversation has to tell «Codex refused» from «Codex was too slow», and the
18
+ * only other way to ask was the sentence in `message`. That sentence is prose,
19
+ * and prose is exactly what turned a slow `thread/resume` into a dead session —
20
+ * `isMissingRollout` matched «no rollout found», not «timed out after 60000ms»,
21
+ * so none of the recovery already written for a refusal ever ran.
22
+ */
23
+ export class RpcTimeoutError extends RpcError {
24
+ constructor(method, timeoutMs) {
25
+ super(-32000, `${method} timed out after ${timeoutMs}ms`, method);
26
+ this.name = 'RpcTimeoutError';
27
+ }
28
+ }
13
29
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
14
30
  /** A line longer than this means the far end is misbehaving — drop the buffer. */
15
31
  const MAX_LINE_BYTES = 8 * 1024 * 1024;
@@ -134,7 +150,7 @@ export class AppServerClient {
134
150
  return new Promise((resolve, reject) => {
135
151
  const timer = setTimeout(() => {
136
152
  this.pending.delete(id);
137
- reject(new RpcError(-32000, `${method} timed out after ${timeoutMs}ms`, method));
153
+ reject(new RpcTimeoutError(method, timeoutMs));
138
154
  }, timeoutMs);
139
155
  timer.unref();
140
156
  this.pending.set(id, {
@@ -3,7 +3,7 @@ import { log } from '../log.js';
3
3
  import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
4
4
  import { RUNNER_VERSION } from '../version.js';
5
5
  import { repairCodexAuth } from './codex-home.js';
6
- import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
6
+ import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
7
7
  import { truncate } from './claude.js';
8
8
  import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
9
9
  import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
@@ -326,13 +326,69 @@ class CodexSession {
326
326
  * second; losing the conversation costs the whole context.
327
327
  */
328
328
  static RESUME_RETRY_DELAY_MS = 1_500;
329
+ /**
330
+ * How long opening an existing conversation may take (#370).
331
+ *
332
+ * Its own budget rather than the protocol's 60 s default, because it is not a
333
+ * command — it is the app-server reading a conversation off disk, and the
334
+ * work grows with the conversation. Session #61 on Athanor died on exactly
335
+ * that: ~6 900 events, `thread/resume timed out after 60000ms`, twice, and
336
+ * the session was gone for good.
337
+ *
338
+ * Measured here on 06.09.2026, codex-cli 0.153.4, against a real 14 MB
339
+ * rollout (1 460 lines):
340
+ *
341
+ * without `excludeTurns` — 5 744 ms, a 4.41 MB response line
342
+ * with `excludeTurns` — 673 ms, a 27 KB response line
343
+ *
344
+ * So 120 s is ~20× the slowest answer this transport can even carry: the
345
+ * stdout reader drops any line over 8 MB (`MAX_LINE_BYTES`), which at the
346
+ * measured 0.77 MB/s is about ten seconds of hydration. The margin is not for
347
+ * a bigger conversation — it is for a loaded or slow machine, which is what
348
+ * #370 is actually about.
349
+ */
350
+ static RESUME_REQUEST_TIMEOUT_MS = 120_000;
351
+ /**
352
+ * Does this build take `excludeTurns`? (#364)
353
+ *
354
+ * Remembered per session rather than probed: the answer belongs to the
355
+ * installed binary, and one refusal is enough to stop asking for the life of
356
+ * this process. Same shape as the `experimentalApi` fallback below it.
357
+ */
358
+ excludeTurnsRefused = false;
359
+ /**
360
+ * Open a thread without asking for its whole history (#364).
361
+ *
362
+ * The history was always thrown away — `readThread` reads the id and the
363
+ * model and nothing else — so asking for it bought a deprecation warning in
364
+ * the user's feed, several megabytes through a pipe that drops anything over
365
+ * eight, and the seconds that killed #370. Old builds that do not know the
366
+ * field are answered by retrying once without it.
367
+ */
368
+ async openThreadRequest(method, params) {
369
+ if (!this.excludeTurnsRefused) {
370
+ try {
371
+ return asRecord(await this.client.request(method, { ...params, excludeTurns: true }, CodexSession.RESUME_REQUEST_TIMEOUT_MS));
372
+ }
373
+ catch (error) {
374
+ if (!isUnknownParam(error))
375
+ throw error;
376
+ this.excludeTurnsRefused = true;
377
+ log.warn('codex: this build does not take excludeTurns — asking for the full history', {
378
+ sessionId: this.spec.sessionId,
379
+ method,
380
+ });
381
+ }
382
+ }
383
+ return asRecord(await this.client.request(method, params, CodexSession.RESUME_REQUEST_TIMEOUT_MS));
384
+ }
329
385
  /** Branch the thread at `lastTurnId`, dropping every later turn. */
330
386
  async forkThread(threadId, lastTurnId) {
331
- const result = asRecord(await this.client.request('thread/fork', {
387
+ const result = await this.openThreadRequest('thread/fork', {
332
388
  threadId,
333
389
  lastTurnId,
334
390
  ...this.threadParams(),
335
- }));
391
+ });
336
392
  const thread = asRecord(result['thread']);
337
393
  const id = str(thread['id']);
338
394
  if (!id)
@@ -344,10 +400,10 @@ class CodexSession {
344
400
  // overlay lives only in memory, so resuming with just a thread id brings the
345
401
  // conversation back without DevBridge access — the agent then hunts for
346
402
  // tickets it can no longer reach (found in the live check).
347
- const result = asRecord(await this.client.request('thread/resume', {
403
+ const result = await this.openThreadRequest('thread/resume', {
348
404
  threadId: resumeId,
349
405
  ...this.threadParams(),
350
- }));
406
+ });
351
407
  const thread = asRecord(result['thread']);
352
408
  const id = str(thread['id']);
353
409
  if (!id)
@@ -392,9 +448,10 @@ class CodexSession {
392
448
  let error = firstError;
393
449
  // Matched on the message rather than the error class: the same failure
394
450
  // can arrive as an RPC error or as a transport error, and a missed match
395
- // would fail the session instead of retrying it.
396
- if (isMissingRollout(error)) {
397
- log.warn('codex: thread/resume rejected — retrying once', {
451
+ // would fail the session instead of retrying it. The one exception is a
452
+ // timeout, which IS an error class (#370) — see `isResumeRecoverable`.
453
+ if (isResumeRecoverable(error)) {
454
+ log.warn('codex: thread/resume did not open the conversation — retrying once', {
398
455
  sessionId: this.spec.sessionId,
399
456
  threadId: resumeId,
400
457
  error: maskString(describe(error)).slice(0, 300),
@@ -411,14 +468,14 @@ class CodexSession {
411
468
  error = retryError;
412
469
  }
413
470
  }
414
- if (isMissingRollout(error)) {
471
+ if (isResumeRecoverable(error)) {
415
472
  // Losing the conversation is expensive, so record WHY. Verified live
416
473
  // (2026-07-25): resuming this exact thread with these exact params
417
474
  // succeeds in isolation, so a failure here is transient — most likely
418
475
  // the previous agent process still holding the thread during a rapid
419
476
  // stop→continue cycle. Without this line the only trace is a feed
420
477
  // notice that says the context is gone and nothing about the cause.
421
- log.warn('codex: thread/resume rejected — falling back to a fresh thread', {
478
+ log.warn('codex: thread/resume gave up — falling back to a fresh thread', {
422
479
  sessionId: this.spec.sessionId,
423
480
  threadId: resumeId,
424
481
  error: maskString(describe(error)).slice(0, 300),
@@ -767,18 +824,23 @@ class CodexSession {
767
824
  }
768
825
  }
769
826
  async interrupt() {
827
+ // No turn of our own to stop — and that is not a failure (#373): a pause
828
+ // that lands between turns has nothing to interrupt and must not be read as
829
+ // a control channel that said no.
770
830
  if (!this.threadId || !this.activeTurnId)
771
- return;
831
+ return 'idle';
772
832
  try {
773
833
  await this.client.request('turn/interrupt', {
774
834
  threadId: this.threadId,
775
835
  turnId: this.activeTurnId,
776
836
  });
837
+ return 'accepted';
777
838
  }
778
839
  catch (error) {
779
840
  if (/no active turn/i.test(describe(error)))
780
- return;
841
+ return 'idle';
781
842
  log.warn('codex: interrupt failed', { error: describe(error) });
843
+ return this.stopped ? 'idle' : 'refused';
782
844
  }
783
845
  }
784
846
  conversationAnchor() {
@@ -1157,10 +1219,13 @@ class CodexSession {
1157
1219
  }
1158
1220
  return;
1159
1221
  }
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.
1222
+ // #279. Kept out of `OPT_OUT_NOTIFICATIONS` on purpose, but NOT because
1223
+ // it is rare: Codex sends it after every model request, as often as
1224
+ // `thread/tokenUsage/updated` (production count 56 591 against 57 008,
1225
+ // 05.09.2026 – #366). What keeps it off the wire is the supervisor's
1226
+ // level gate, which forwards a snapshot only when it changed, carries a
1227
+ // refusal (`blocked`), or is older than the resend floor. Opting the
1228
+ // notification out here instead would silence the refusal too.
1164
1229
  case 'account/rateLimits/updated': {
1165
1230
  this.onRateLimits(params);
1166
1231
  return;
@@ -1802,6 +1867,9 @@ class CodexSession {
1802
1867
  this.emit({
1803
1868
  type: 'error',
1804
1869
  message: `codex app-server exited before the session was ready (code ${info.code ?? 'null'})`,
1870
+ // #373: the process is what ended, not a turn. `stopped` is already
1871
+ // excluded above, so this is always an exit nobody here asked for.
1872
+ processGone: true,
1805
1873
  });
1806
1874
  }
1807
1875
  this.finish();
@@ -2096,6 +2164,51 @@ function describe(error) {
2096
2164
  function isMissingRollout(error) {
2097
2165
  return /no rollout found|not found/i.test(describe(error));
2098
2166
  }
2167
+ /**
2168
+ * The conversation did not open, and a fresh one is the way out (#370).
2169
+ *
2170
+ * Two ways for that to happen, and until this ticket only the first counted:
2171
+ *
2172
+ * - the app-server REFUSED — there is no such rollout. Survivable: the retry
2173
+ * below, then a fresh thread, and the branch and files are untouched.
2174
+ * - the app-server never ANSWERED. Read as «something we do not recognise»,
2175
+ * which fails the session outright — so a conversation that had merely grown
2176
+ * slow was more fatal than one that had been thrown away.
2177
+ *
2178
+ * The timeout is recognised by its class and not by its sentence: `RpcTimeoutError`
2179
+ * is minted in exactly one place, whereas «timed out» is a phrase any layer
2180
+ * between here and the provider may use about something else entirely.
2181
+ */
2182
+ function isResumeRecoverable(error) {
2183
+ return error instanceof RpcTimeoutError || isMissingRollout(error);
2184
+ }
2185
+ /**
2186
+ * Did the app-server refuse a parameter it does not know? (#364)
2187
+ *
2188
+ * JSON-RPC says «invalid params» is -32602, and that code alone is taken at its
2189
+ * word. App-server also answers -32600 for a field gated behind a capability,
2190
+ * but -32600 is what a stale rollout arrives as too (see the QA-101 tests), so
2191
+ * there the message has to name the field as well — reading a lost conversation
2192
+ * as «this build is old» would double every attempt on the one path that is
2193
+ * already losing it.
2194
+ *
2195
+ * Anything else — a timeout, a missing rollout, a transport failure — must NOT
2196
+ * be re-sent, or one slow resume would become two.
2197
+ */
2198
+ function isUnknownParam(error) {
2199
+ if (error instanceof RpcTimeoutError)
2200
+ return false;
2201
+ if (!(error instanceof RpcError))
2202
+ return false;
2203
+ // A stale rollout also arrives as -32600 (see the QA-101 tests), and reading
2204
+ // that as «this build is old» would double every resume attempt on the very
2205
+ // path that is already losing a conversation.
2206
+ if (isMissingRollout(error))
2207
+ return false;
2208
+ if (error.code === -32602)
2209
+ return true;
2210
+ return /unknown field|unknown parameter|unexpected field|excludeTurns/i.test(error.message);
2211
+ }
2099
2212
  function delay(ms) {
2100
2213
  return new Promise((resolve) => {
2101
2214
  const timer = setTimeout(resolve, ms);
@@ -543,7 +543,45 @@ export type AgentEvent = {
543
543
  * all. Both still have to withdraw the feed cut and say what happened.
544
544
  */
545
545
  recovered?: boolean;
546
+ /**
547
+ * The agent PROCESS ended with this — it is not an error the running
548
+ * agent reported (#373).
549
+ *
550
+ * Set only where the adapter's own read loop broke, i.e. where the CLI
551
+ * exited under it. The supervisor needs the difference to tell the tail of
552
+ * a stop it asked for from a process that died on its own: closing a
553
+ * process during a pause produces an ending, and an ending must not read
554
+ * as a failure — while a process that fell over BEFORE we closed it has
555
+ * genuinely failed and must still say so.
556
+ *
557
+ * A flag rather than a sentence on purpose. The wording of these errors
558
+ * comes from the CLI and changes with it; «who ended this process» is a
559
+ * fact only the adapter knows.
560
+ */
561
+ processGone?: boolean;
546
562
  };
563
+ /**
564
+ * What became of an interrupt request (#373).
565
+ *
566
+ * `interrupt()` used to answer `void`, so «the CLI took the stop» and «the
567
+ * control channel refused it» were the same event from the supervisor's side —
568
+ * and a refusal that arrived one line before a pause was filed as a clean
569
+ * cancellation. The three outcomes are what a stop coordinator has to tell
570
+ * apart to decide whether the process may be closed quietly.
571
+ */
572
+ export type InterruptOutcome =
573
+ /** The CLI acknowledged the request. A result for the turn should follow. */
574
+ 'accepted'
575
+ /**
576
+ * There is nothing to stop.
577
+ *
578
+ * Either no turn was running, or this session has already been closed — by us
579
+ * — and the control request went with it. Both mean the same thing to the
580
+ * caller: no result is coming, and nobody refused anything.
581
+ */
582
+ | 'idle'
583
+ /** The control channel said no, or died answering. The turn may still run. */
584
+ | 'refused';
547
585
  export interface AgentSession {
548
586
  /** Ends when the underlying agent process is gone. */
549
587
  events: AsyncIterable<AgentEvent>;
@@ -615,8 +653,14 @@ export interface AgentSession {
615
653
  */
616
654
  gitPolicy?: AgentGitPolicy;
617
655
  }): void;
618
- /** Interrupt the current turn (session stays resumable). */
619
- interrupt(): Promise<void>;
656
+ /**
657
+ * Interrupt the current turn (session stays resumable).
658
+ *
659
+ * Answers what became of the request (#373). The supervisor closes the
660
+ * process itself after a pause, and «may I close it quietly» has a different
661
+ * answer for a stop the CLI took and one it refused.
662
+ */
663
+ interrupt(): Promise<InterruptOutcome>;
620
664
  /**
621
665
  * An opaque id naming the conversation as it stands RIGHT NOW (ticket #126).
622
666
  *
@@ -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