@bridge4dev/runner 0.41.0 → 0.42.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.
@@ -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,44 @@ 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
+ * Read the percentages out of the CLI's own `/usage` (#279).
706
+ *
707
+ * Fire-and-forget, throttled, and free — a local command, not a request
708
+ * (probe 2026-08-15: zero tokens, zero turns, zero dollars). The live event
709
+ * gives us the clock and almost never the number; this gives the number and
710
+ * never touches the clock.
711
+ */
712
+ refreshUsage() {
713
+ if (this.stopped)
714
+ return;
715
+ if (this.usageProbedAt !== 0 && Date.now() - this.usageProbedAt < USAGE_PROBE_INTERVAL_MS) {
716
+ return;
717
+ }
718
+ const binary = claudeCliPath();
719
+ if (!binary)
720
+ return;
721
+ this.usageProbedAt = Date.now();
722
+ void probeUsageText(binary, this.spec.cwd)
723
+ .then((text) => {
724
+ if (!text || this.stopped)
725
+ return;
726
+ const rows = parseUsageText(text);
727
+ if (rows.length === 0)
728
+ return;
729
+ // A percentage proves a plan as surely as the event does.
730
+ this.rateLimitsAvailable = true;
731
+ const merged = applyUsagePercentages([...this.rateLimitWindows.values()], rows);
732
+ this.rateLimitWindows.clear();
733
+ for (const window of merged) {
734
+ this.rateLimitWindows.set(`${window.key}:${window.label ?? ''}`, window);
735
+ }
736
+ this.emitRateLimits();
737
+ })
738
+ .catch((error) => log.debug('claude: usage probe failed', { error: String(error) }));
739
+ }
698
740
  /** Read the flag and clear it: one refusal marks exactly one turn end. */
699
741
  consumeLimitBlock() {
700
742
  const blocked = this.limitBlockPending;
@@ -719,6 +761,16 @@ class ClaudeSession {
719
761
  }
720
762
  emitRateLimitsFromInit(msg) {
721
763
  const init = msg;
764
+ // Probe against the installed CLI, 2026-08-15: `system:init` carries none of
765
+ // these keys at all — not `false`, ABSENT. The fields exist in the SDK's own
766
+ // typings, which is what this code trusted first, and the result was a panel
767
+ // stating «лимитов подписки нет» to an owner who has a subscription. Absence
768
+ // is «we do not know yet», and only a `rate_limit_event` can settle it.
769
+ if (init.rate_limits_available === undefined) {
770
+ if (typeof init.subscription_type === 'string')
771
+ this.ratePlanType = init.subscription_type;
772
+ return;
773
+ }
722
774
  this.rateLimitsAvailable = init.rate_limits_available === true;
723
775
  this.ratePlanType = typeof init.subscription_type === 'string' ? init.subscription_type : null;
724
776
  this.rateLimitWindows.clear();
@@ -747,25 +799,32 @@ class ClaudeSession {
747
799
  // without a percentage: the refusal is the fact, the number is decoration.
748
800
  // `allowed_warning` is NOT a refusal — the turn ran.
749
801
  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`.
802
+ // NOT gated on the percentage. Probe against the installed CLI, 2026-08-15:
803
+ // every request emits this event, and `utilization` is usually absent —
804
+ // {status:'allowed', resetsAt:1786826400, rateLimitType:'five_hour', …}
805
+ // Requiring the number threw the whole event away, so the one source that
806
+ // does work was silently discarded and the panel had nothing to show. What
807
+ // the event always carries is WHICH window and WHEN it resets, and that is
808
+ // worth drawing on its own.
809
+ // A live event also proves there IS a plan: the SDK emits it only for
810
+ // claude.ai subscriptions.
755
811
  this.rateLimitsAvailable = true;
756
812
  const name = typeof info['rateLimitType'] === 'string' ? info['rateLimitType'] : 'five_hour';
757
813
  const key = rateWindowKey(name);
758
814
  // `resetsAt` here is epoch SECONDS, unlike `resets_at` on init which is
759
815
  // already ISO. Same fact, two encodings, one place that knows it.
760
816
  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
- }
817
+ // Keep whatever the previous event said about the percentage: the field
818
+ // comes and goes between events about the same window, and dropping it on
819
+ // the next silent one would make a number that was true flicker away.
820
+ const known = this.rateLimitWindows.get(name);
821
+ this.rateLimitWindows.set(name, {
822
+ key,
823
+ windowMinutes: RATE_WINDOW_MINUTES[key] ?? null,
824
+ usedPercent: typeof utilization === 'number' ? clampPercent(utilization) : (known?.usedPercent ?? null),
825
+ resetsAt: resetsAt ?? known?.resetsAt ?? null,
826
+ status: typeof info['status'] === 'string' ? info['status'] : null,
827
+ });
769
828
  if (refused)
770
829
  this.limitBlockPending = true;
771
830
  this.emitRateLimits(refused ? { key, resetsAt } : null);
@@ -1885,6 +1944,8 @@ class ClaudeSession {
1885
1944
  // account has none — «this organization runs on its own key» is an
1886
1945
  // answer the popup has to be able to give.
1887
1946
  this.emitRateLimitsFromInit(msg);
1947
+ // #279: the percentages the event does not carry.
1948
+ this.refreshUsage();
1888
1949
  }
1889
1950
  else if (msg.subtype === 'status') {
1890
1951
  const status = msg.status;
@@ -2040,6 +2101,9 @@ class ClaudeSession {
2040
2101
  // refused is not a turn that ran.
2041
2102
  ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2042
2103
  });
2104
+ // #279: a turn just moved the plan, so the percentages are worth
2105
+ // re-reading — throttled inside, and free either way.
2106
+ this.refreshUsage();
2043
2107
  }
2044
2108
  else {
2045
2109
  this.emit({
@@ -2048,6 +2112,7 @@ class ClaudeSession {
2048
2112
  errorMessage: failure,
2049
2113
  ...(this.consumeLimitBlock() ? { limitBlocked: true } : {}),
2050
2114
  });
2115
+ this.refreshUsage();
2051
2116
  }
2052
2117
  break;
2053
2118
  }
@@ -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,
@@ -1432,7 +1446,7 @@ class CodexSession {
1432
1446
  haystack.includes('usagelimitreached');
1433
1447
  if (!refused)
1434
1448
  return null;
1435
- const fullest = this.rateLimitWindows.reduce((worst, window) => worst === null || window.usedPercent > worst.usedPercent ? window : worst, null);
1449
+ const fullest = this.rateLimitWindows.reduce((worst, window) => worst === null || (window.usedPercent ?? 0) > (worst.usedPercent ?? 0) ? window : worst, null);
1436
1450
  return { key: fullest?.key ?? 'other', resetsAt: fullest?.resetsAt ?? null };
1437
1451
  }
1438
1452
  onTurnCompleted(params) {
@@ -149,10 +149,24 @@ export interface AgentRateLimitWindow {
149
149
  key: 'five_hour' | 'seven_day' | 'other';
150
150
  /** Length of the window, when the provider states it. */
151
151
  windowMinutes: number | null;
152
- /** 0–100. What is SPENT, not what is left — both CLIs report it that way. */
153
- usedPercent: number;
152
+ /**
153
+ * 0–100, SPENT rather than left — and **nullable**.
154
+ *
155
+ * Claude's live event carries the window and its reset time on every request
156
+ * but usually omits the percentage (probe, 2026-08-15). A window we can date
157
+ * but not measure is still worth showing; requiring the number here is what
158
+ * made the panel say «no plan» to an account that has one.
159
+ */
160
+ usedPercent: number | null;
154
161
  /** Absolute moment, ISO 8601. Null when the provider did not say. */
155
162
  resetsAt: string | null;
163
+ /** `allowed` · `allowed_warning` · `rejected`, when the provider says. */
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;
156
170
  }
157
171
  /**
158
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.0";
1
+ export declare const RUNNER_VERSION = "0.42.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.41.0';
2
+ export const RUNNER_VERSION = '0.42.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.41.0",
3
+ "version": "0.42.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",