@bridge4dev/runner 0.41.1 → 0.42.1

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.
@@ -0,0 +1,34 @@
1
+ import type { AgentRateLimitWindow } from './types.js';
2
+ export interface UsageRow {
3
+ key: 'five_hour' | 'seven_day';
4
+ /** «Weekly · Fable» for a per-model row; absent for the plain ones. */
5
+ label: string | null;
6
+ percent: number;
7
+ }
8
+ /**
9
+ * Every percentage `/usage` printed, in the order it printed them.
10
+ *
11
+ * The per-model weekly rows («Current week (Fable): 0% used») are kept rather
12
+ * than dropped — owner's call, 2026-08-15: on a plan where one model has its
13
+ * own weekly allowance, «50% of the week» is only half the answer.
14
+ *
15
+ * Deliberately forgiving: a line we cannot read is skipped, never thrown over.
16
+ */
17
+ export declare function parseUsageText(text: string): UsageRow[];
18
+ /**
19
+ * Merge fresh percentages into the windows we already know about.
20
+ *
21
+ * The RESET TIME is never taken from here — whatever the event said stays. A
22
+ * window the event has not mentioned yet is created so the first probe is not
23
+ * wasted, with no clock on it until the event supplies one.
24
+ */
25
+ export declare function applyUsagePercentages(windows: AgentRateLimitWindow[], rows: UsageRow[]): AgentRateLimitWindow[];
26
+ /**
27
+ * Run `/usage` in a throwaway process and return what it printed.
28
+ *
29
+ * `--output-format text` rather than the stream: we want the rendering, and the
30
+ * JSON wrapper would only have to be unwrapped again. Never throws — a probe
31
+ * that fails is a percentage we do not show, not a session that breaks.
32
+ */
33
+ export declare function probeUsageText(binary: string, cwd: string, timeoutMs?: number): Promise<string | null>;
34
+ //# sourceMappingURL=claude-usage.d.ts.map
@@ -0,0 +1,111 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { log } from '../log.js';
3
+ /**
4
+ * How much of the plan is spent — read from the CLI's own `/usage` (#279).
5
+ *
6
+ * The machine-readable event (`rate_limit_event`) is the source of truth for
7
+ * WHEN a window resets, and the auto-pause clock is built on it alone. What it
8
+ * almost never carries is the PERCENTAGE, and that is the number the owner
9
+ * actually asked to see. `/usage` has it.
10
+ *
11
+ * Two things make this worth doing despite being text:
12
+ *
13
+ * - it is FREE. Probed 2026-08-15: `total_cost_usd: 0`, `num_turns: 0`,
14
+ * 0 input and 0 output tokens, ~3.7s. It is a local command, not a request —
15
+ * measuring the limit does not spend it.
16
+ * - it can only ever affect a NUMBER ON A SCREEN. The reset time we schedule a
17
+ * wake-up from never comes from here, so a wording change in a future CLI
18
+ * costs us the percentage and nothing else. That is the exact failure #258
19
+ * refused to accept for the clock, and the exact risk it is fine to take for
20
+ * a label.
21
+ *
22
+ * The text, as of 2026-08-15:
23
+ *
24
+ * Current session: 63% used · resets Aug 15, 10:39pm (Europe/Berlin)
25
+ * Current week (all models): 50% used · resets Aug 19, 3:59pm (Europe/Berlin)
26
+ * Current week (Fable): 0% used
27
+ */
28
+ /** «Current session» is the five-hour window; every «Current week» is a weekly one. */
29
+ const SESSION_LINE = /current session:\s*(\d+(?:\.\d+)?)%\s*used/i;
30
+ const WEEK_LINE = /current week(?:\s*\(([^)]+)\))?:\s*(\d+(?:\.\d+)?)%\s*used/gi;
31
+ const clamp = (raw) => {
32
+ if (raw === undefined)
33
+ return null;
34
+ const value = Number(raw);
35
+ return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : null;
36
+ };
37
+ /**
38
+ * Every percentage `/usage` printed, in the order it printed them.
39
+ *
40
+ * The per-model weekly rows («Current week (Fable): 0% used») are kept rather
41
+ * than dropped — owner's call, 2026-08-15: on a plan where one model has its
42
+ * own weekly allowance, «50% of the week» is only half the answer.
43
+ *
44
+ * Deliberately forgiving: a line we cannot read is skipped, never thrown over.
45
+ */
46
+ export function parseUsageText(text) {
47
+ const rows = [];
48
+ const session = clamp(SESSION_LINE.exec(text)?.[1]);
49
+ if (session !== null)
50
+ rows.push({ key: 'five_hour', label: null, percent: session });
51
+ WEEK_LINE.lastIndex = 0;
52
+ for (let match = WEEK_LINE.exec(text); match !== null; match = WEEK_LINE.exec(text)) {
53
+ const percent = clamp(match[2]);
54
+ if (percent === null)
55
+ continue;
56
+ const scope = (match[1] ?? '').trim();
57
+ // «all models» IS the weekly window; anything else is that model's own.
58
+ const isAll = scope === '' || /^all models$/i.test(scope);
59
+ rows.push({ key: 'seven_day', label: isAll ? null : `Weekly · ${scope}`, percent });
60
+ }
61
+ return rows;
62
+ }
63
+ /**
64
+ * Merge fresh percentages into the windows we already know about.
65
+ *
66
+ * The RESET TIME is never taken from here — whatever the event said stays. A
67
+ * window the event has not mentioned yet is created so the first probe is not
68
+ * wasted, with no clock on it until the event supplies one.
69
+ */
70
+ export function applyUsagePercentages(windows, rows) {
71
+ // Keyed by window AND label: a per-model weekly row is its own line, not an
72
+ // overwrite of the plan-wide one.
73
+ const id = (window) => `${window.key}:${window.label ?? ''}`;
74
+ const merged = new Map(windows.map((window) => [id(window), { ...window }]));
75
+ for (const row of rows) {
76
+ const key = `${row.key}:${row.label ?? ''}`;
77
+ const existing = merged.get(key);
78
+ merged.set(key, {
79
+ key: row.key,
80
+ windowMinutes: existing?.windowMinutes ?? (row.key === 'five_hour' ? 300 : 10_080),
81
+ usedPercent: row.percent,
82
+ // The clock stays whatever the machine-readable event said — this file
83
+ // never sets it, so a wording change here can never move a wake-up.
84
+ resetsAt: existing?.resetsAt ?? null,
85
+ ...(existing?.status ? { status: existing.status } : {}),
86
+ ...(row.label ? { label: row.label } : {}),
87
+ });
88
+ }
89
+ return [...merged.values()].sort((a, b) => (a.windowMinutes ?? Number.MAX_SAFE_INTEGER) - (b.windowMinutes ?? Number.MAX_SAFE_INTEGER) ||
90
+ (a.label ?? '').localeCompare(b.label ?? ''));
91
+ }
92
+ /**
93
+ * Run `/usage` in a throwaway process and return what it printed.
94
+ *
95
+ * `--output-format text` rather than the stream: we want the rendering, and the
96
+ * JSON wrapper would only have to be unwrapped again. Never throws — a probe
97
+ * that fails is a percentage we do not show, not a session that breaks.
98
+ */
99
+ export function probeUsageText(binary, cwd, timeoutMs = 20_000) {
100
+ return new Promise((resolve) => {
101
+ execFile(binary, ['-p', '/usage', '--output-format', 'text'], { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 }, (error, stdout) => {
102
+ if (error) {
103
+ log.debug('claude: /usage probe failed', { error: String(error) });
104
+ resolve(null);
105
+ return;
106
+ }
107
+ resolve(stdout);
108
+ });
109
+ });
110
+ }
111
+ //# sourceMappingURL=claude-usage.js.map
@@ -7,6 +7,10 @@ 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
9
  import { clampPercent, RATE_WINDOW_MINUTES, rateWindowKey } from './rate-limits.js';
10
+ import { applyUsagePercentages, parseUsageText, probeUsageText } from './claude-usage.js';
11
+ import { claudeCliPath } from '../agent-binary.js';
12
+ /** How often `/usage` may be read. Free, but still a process. */
13
+ const USAGE_PROBE_INTERVAL_MS = 3 * 60 * 1000;
10
14
  import { answerSummary, answerValue, discussMessage, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
11
15
  // Claude adapter over the Agent SDK. Three live-verified gotchas (plan §2):
12
16
  // 1. Bare tool names in `allowedTools` auto-approve BEFORE canUseTool — we
@@ -695,6 +699,55 @@ class ClaudeSession {
695
699
  rateLimitsAvailable = false;
696
700
  /** A refusal seen since the last turn ended, waiting to be reported with it. */
697
701
  limitBlockPending = false;
702
+ /** When `/usage` was last read, so a busy session does not spawn a process a second. */
703
+ usageProbedAt = 0;
704
+ /**
705
+ * The one key the window map is written under, from every source.
706
+ *
707
+ * Live 2026-08-15: the event wrote under the provider's raw name
708
+ * (`five_hour`) while the `/usage` merge wrote under `five_hour:` — so the
709
+ * same window arrived twice, once with the clock and once with the number,
710
+ * and the panel drew both rows. One scheme, one row.
711
+ */
712
+ static windowKey(key, label) {
713
+ return `${key}:${label ?? ''}`;
714
+ }
715
+ /**
716
+ * Read the percentages out of the CLI's own `/usage` (#279).
717
+ *
718
+ * Fire-and-forget, throttled, and free — a local command, not a request
719
+ * (probe 2026-08-15: zero tokens, zero turns, zero dollars). The live event
720
+ * gives us the clock and almost never the number; this gives the number and
721
+ * never touches the clock.
722
+ */
723
+ refreshUsage() {
724
+ if (this.stopped)
725
+ return;
726
+ if (this.usageProbedAt !== 0 && Date.now() - this.usageProbedAt < USAGE_PROBE_INTERVAL_MS) {
727
+ return;
728
+ }
729
+ const binary = claudeCliPath();
730
+ if (!binary)
731
+ return;
732
+ this.usageProbedAt = Date.now();
733
+ void probeUsageText(binary, this.spec.cwd)
734
+ .then((text) => {
735
+ if (!text || this.stopped)
736
+ return;
737
+ const rows = parseUsageText(text);
738
+ if (rows.length === 0)
739
+ return;
740
+ // A percentage proves a plan as surely as the event does.
741
+ this.rateLimitsAvailable = true;
742
+ const merged = applyUsagePercentages([...this.rateLimitWindows.values()], rows);
743
+ this.rateLimitWindows.clear();
744
+ for (const window of merged) {
745
+ this.rateLimitWindows.set(ClaudeSession.windowKey(window.key, window.label), window);
746
+ }
747
+ this.emitRateLimits();
748
+ })
749
+ .catch((error) => log.debug('claude: usage probe failed', { error: String(error) }));
750
+ }
698
751
  /** Read the flag and clear it: one refusal marks exactly one turn end. */
699
752
  consumeLimitBlock() {
700
753
  const blocked = this.limitBlockPending;
@@ -738,7 +791,7 @@ class ClaudeSession {
738
791
  if (!window || typeof window.utilization !== 'number')
739
792
  continue;
740
793
  const key = rateWindowKey(name);
741
- this.rateLimitWindows.set(name, {
794
+ this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
742
795
  key,
743
796
  windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
744
797
  usedPercent: clampPercent(window.utilization),
@@ -775,8 +828,8 @@ class ClaudeSession {
775
828
  // Keep whatever the previous event said about the percentage: the field
776
829
  // comes and goes between events about the same window, and dropping it on
777
830
  // the next silent one would make a number that was true flicker away.
778
- const known = this.rateLimitWindows.get(name);
779
- this.rateLimitWindows.set(name, {
831
+ const known = this.rateLimitWindows.get(ClaudeSession.windowKey(key));
832
+ this.rateLimitWindows.set(ClaudeSession.windowKey(key), {
780
833
  key,
781
834
  windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
782
835
  usedPercent: typeof utilization === 'number' ? clampPercent(utilization) : (known?.usedPercent ?? null),
@@ -1902,6 +1955,8 @@ class ClaudeSession {
1902
1955
  // account has none — «this organization runs on its own key» is an
1903
1956
  // answer the popup has to be able to give.
1904
1957
  this.emitRateLimitsFromInit(msg);
1958
+ // #279: the percentages the event does not carry.
1959
+ this.refreshUsage();
1905
1960
  }
1906
1961
  else if (msg.subtype === 'status') {
1907
1962
  const status = msg.status;
@@ -2057,6 +2112,9 @@ class ClaudeSession {
2057
2112
  // refused is not a turn that ran.
2058
2113
  ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2059
2114
  });
2115
+ // #279: a turn just moved the plan, so the percentages are worth
2116
+ // re-reading — throttled inside, and free either way.
2117
+ this.refreshUsage();
2060
2118
  }
2061
2119
  else {
2062
2120
  this.emit({
@@ -2065,6 +2123,7 @@ class ClaudeSession {
2065
2123
  errorMessage: failure,
2066
2124
  ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2067
2125
  });
2126
+ this.refreshUsage();
2068
2127
  }
2069
2128
  break;
2070
2129
  }
@@ -1385,8 +1385,22 @@ class CodexSession {
1385
1385
  if (usedPercent === undefined)
1386
1386
  continue;
1387
1387
  const minutes = num(window['windowMinutes'] ?? window['window_minutes']) ?? null;
1388
+ // Live 2026-08-15: Codex sent a percentage with NO length, so the label
1389
+ // fell back to «Limit window» — true but useless. The slot itself carries
1390
+ // the answer on every ChatGPT plan: `primary` is the short window,
1391
+ // `secondary` the weekly one. Used only when the minutes are missing, so
1392
+ // a provider that does state them still wins.
1393
+ // Only when the length is ABSENT. A stated length we do not recognise is
1394
+ // information — 43 200 minutes is a monthly window, and calling it «5
1395
+ // hours» because it arrived first would be worse than admitting we have
1396
+ // no name for it.
1397
+ const key = minutes === null
1398
+ ? slot === 'primary'
1399
+ ? 'five_hour'
1400
+ : 'seven_day'
1401
+ : rateWindowKeyFromMinutes(minutes);
1388
1402
  windows.push({
1389
- key: rateWindowKeyFromMinutes(minutes),
1403
+ key,
1390
1404
  windowMinutes: minutes,
1391
1405
  usedPercent: clampPercent(usedPercent),
1392
1406
  resetsAt: str(window['resetsAt'] ?? window['resets_at']) ?? null,
@@ -162,6 +162,11 @@ export interface AgentRateLimitWindow {
162
162
  resetsAt: string | null;
163
163
  /** `allowed` · `allowed_warning` · `rejected`, when the provider says. */
164
164
  status?: string | null;
165
+ /**
166
+ * What to call this row when our own vocabulary is not enough — «Weekly ·
167
+ * Fable» for a model with its own weekly allowance (owner's call, #279).
168
+ */
169
+ label?: string | null;
165
170
  }
166
171
  /**
167
172
  * The account's plan usage, as one snapshot (#279, #258).
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const RUNNER_VERSION = "0.41.1";
1
+ export declare const RUNNER_VERSION = "0.42.1";
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.41.1';
2
+ export const RUNNER_VERSION = '0.42.1';
3
3
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bridge4dev/runner",
3
- "version": "0.41.1",
3
+ "version": "0.42.1",
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",