@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.
- package/dist/adapters/claude-usage.d.ts +34 -0
- package/dist/adapters/claude-usage.js +111 -0
- package/dist/adapters/claude.js +78 -13
- package/dist/adapters/codex.js +16 -2
- package/dist/adapters/types.d.ts +16 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
package/dist/adapters/claude.js
CHANGED
|
@@ -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
|
-
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
//
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
}
|
package/dist/adapters/codex.js
CHANGED
|
@@ -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
|
|
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) {
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
153
|
-
|
|
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.
|
|
1
|
+
export declare const RUNNER_VERSION = "0.42.0";
|
|
2
2
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/version.js
CHANGED
package/package.json
CHANGED