@opsee/cli 0.11.13 → 0.11.18
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 +116 -0
- package/package.json +2 -2
- package/src/args.ts +167 -15
- package/src/cli.ts +62 -6
- package/src/commands/account-usage.ts +106 -0
- package/src/commands/account.ts +134 -1
- package/src/commands/claude-launcher.ts +183 -0
- package/src/commands/foreman-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- package/src/foreman/account.ts +106 -13
- package/src/foreman/claude-worker-adapter.ts +100 -1
- package/src/foreman/config-skeleton.ts +167 -0
- package/src/foreman/core/run.ts +157 -7
- package/src/foreman/core/scheduler.ts +139 -0
- package/src/foreman/core/text.ts +29 -0
- package/src/foreman/credential-store.ts +247 -0
- package/src/foreman/usage-activity.ts +102 -0
- package/src/foreman/usage-format.ts +107 -0
- package/src/foreman/usage-poller.ts +489 -0
- package/src/foreman/usage-store.ts +213 -0
- package/src/foreman/usage.ts +257 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/worker-adapter.ts +12 -0
package/src/foreman/account.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { constants } from "node:fs";
|
|
1
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { isVendor, VENDORS, type Vendor } from "./vendor.js";
|
|
5
5
|
import type { AccountStore } from "./account-store.js";
|
|
6
|
-
import { oneLine, printableOneLine } from "./core/text.js";
|
|
6
|
+
import { formatTable, oneLine, printableOneLine } from "./core/text.js";
|
|
7
|
+
import { describeAge, headroom, thresholdHold, type AccountUsage } from "./usage.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* An Account (see ../../CONTEXT.md): one identity for one vendor, registered by path or key
|
|
@@ -26,6 +27,22 @@ export const DEFAULT_CAP = 2;
|
|
|
26
27
|
* nothing below would refuse it: the Run would obligingly put a hundred Workers on one login. */
|
|
27
28
|
export const MAX_CAP = 32;
|
|
28
29
|
|
|
30
|
+
/**
|
|
31
|
+
* How much of a window may be spent before the Foreman stops offering the Account new Tasks (the
|
|
32
|
+
* multi-account headroom design, §4).
|
|
33
|
+
*
|
|
34
|
+
* 90 because that is the point Claude Code fires its own `allowed_warning` — so at the default the
|
|
35
|
+
* Foreman acts on the first warning the vendor gives rather than on a number it invented.
|
|
36
|
+
*/
|
|
37
|
+
export const DEFAULT_THRESHOLD_PCT = 90;
|
|
38
|
+
|
|
39
|
+
/** The lowest threshold that still leaves an Account usable. Below this a busy Account spends most
|
|
40
|
+
* of every window held back, which is indistinguishable from not having registered it. */
|
|
41
|
+
export const MIN_THRESHOLD_PCT = 50;
|
|
42
|
+
|
|
43
|
+
/** A threshold of 100 is "hold only what the vendor says is spent"; past that is not a percentage. */
|
|
44
|
+
export const MAX_THRESHOLD_PCT = 100;
|
|
45
|
+
|
|
29
46
|
/** The least a stall timeout may be set to. Below this a Worker that is only thinking, or running
|
|
30
47
|
* the project's test suite, is stopped as `stalled` every time, and the Task spends its attempts
|
|
31
48
|
* (`DEFAULT_ATTEMPT_RETRIES`) in seconds without a single turn having had a chance. */
|
|
@@ -148,6 +165,21 @@ export function resumeAccount(store: AccountStore, name: string): Account | unde
|
|
|
148
165
|
return updateAccount(store, name, (account) => ({ ...account, paused: null }));
|
|
149
166
|
}
|
|
150
167
|
|
|
168
|
+
/**
|
|
169
|
+
* Whether this Account could take work at this moment: not quarantined, not Paused, and not one
|
|
170
|
+
* another Foreman already has Workers on.
|
|
171
|
+
*
|
|
172
|
+
* One name for the question, because it was being asked in pieces. `core/scheduler.ts` says plainly
|
|
173
|
+
* that eligibility "is decided in core/run.ts before and after this, and deliberately not here", so
|
|
174
|
+
* a third hand-rolled `filter(a => !isQuarantined(a))` in a command module was the shape of that
|
|
175
|
+
* rule coming apart.
|
|
176
|
+
*
|
|
177
|
+
* `busy` is the Process Table's answer where a caller has one; without it the other two still hold.
|
|
178
|
+
*/
|
|
179
|
+
export function isUsableNow(account: Pick<Account, "name" | "paused" | "quarantined">, now: number, busy?: (name: string) => boolean): boolean {
|
|
180
|
+
return !isQuarantined(account) && !isPaused(account, now) && !busy?.(account.name);
|
|
181
|
+
}
|
|
182
|
+
|
|
151
183
|
/** True while the Account is quarantined (OPS-288). No `now`, unlike `isPaused`: a quarantine has no
|
|
152
184
|
* reset, so nothing about it changes with the clock. */
|
|
153
185
|
export function isQuarantined(account: Pick<Account, "quarantined">): boolean {
|
|
@@ -242,6 +274,9 @@ interface AccountBase {
|
|
|
242
274
|
/** How long a Worker on this Account may go silent before its turn is stopped as `stalled`
|
|
243
275
|
* (story 33). Unset falls back to the Run's `--stall-timeout` and then to the CLI's default. */
|
|
244
276
|
stallTimeoutMs?: number;
|
|
277
|
+
/** How much of a window may be spent before this Account is held back (`DEFAULT_THRESHOLD_PCT`).
|
|
278
|
+
* Per-Account because a Max subscription and a Pro one do not want the same number. */
|
|
279
|
+
thresholdPct?: number;
|
|
245
280
|
}
|
|
246
281
|
|
|
247
282
|
/** A subscription the user has already signed into, isolated in its own config directory; the
|
|
@@ -270,6 +305,11 @@ export interface ConfigDirFs {
|
|
|
270
305
|
accessSync(path: string, mode: number): void;
|
|
271
306
|
}
|
|
272
307
|
|
|
308
|
+
/** The real file system behind `ConfigDirFs`: the two calls registration is allowed to make, and
|
|
309
|
+
* no more. Here rather than in the CLI so that everything registering an Account — the command, its
|
|
310
|
+
* tests — goes through the same two functions the boundary test audits. */
|
|
311
|
+
export const realConfigDirFs: ConfigDirFs = { statSync, accessSync };
|
|
312
|
+
|
|
273
313
|
/** A registration the user got wrong; reported as a plain message, never a stack. */
|
|
274
314
|
export class AccountError extends Error {}
|
|
275
315
|
|
|
@@ -285,7 +325,9 @@ export interface AccountSpec {
|
|
|
285
325
|
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
286
326
|
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
287
327
|
|
|
288
|
-
|
|
328
|
+
/** `~` and `~/…` against the user's home. Exported because `account add --login` expands the same
|
|
329
|
+
* flag before the vendor sees it, and two implementations drifted on bare `~`. */
|
|
330
|
+
export function expandHome(path: string): string {
|
|
289
331
|
return path === "~" || path.startsWith("~/") ? homedir() + path.slice(1) : path;
|
|
290
332
|
}
|
|
291
333
|
|
|
@@ -319,7 +361,17 @@ export function validateConfigDir(fs: ConfigDirFs, dir: string): string {
|
|
|
319
361
|
|
|
320
362
|
/** Validates the spec against the file system and the existing Accounts, then stores the new one.
|
|
321
363
|
* Names are unique across vendors; a directory or key may back only one Account per vendor. */
|
|
322
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Everything `registerAccount` refuses a spec for, *except* what needs the file system.
|
|
366
|
+
*
|
|
367
|
+
* Split out so a caller that is about to do something irreversible on the spec's behalf can find
|
|
368
|
+
* out first: `account add --login` makes a directory and runs an interactive sign-in, and running
|
|
369
|
+
* all of this afterwards meant a name collision cost the user a full login and registered nothing.
|
|
370
|
+
*
|
|
371
|
+
* Returns the validated vendor and name; throws `AccountError` exactly as registration does, so the
|
|
372
|
+
* message a caller sees is the same either way.
|
|
373
|
+
*/
|
|
374
|
+
export function validateAccountSpec(store: AccountStore, spec: AccountSpec): { vendor: Vendor; name: string } {
|
|
323
375
|
if (!isVendor(spec.vendor)) {
|
|
324
376
|
throw new AccountError(`Unknown vendor "${spec.vendor}". Vendors: ${VENDORS.join(", ")}`);
|
|
325
377
|
}
|
|
@@ -338,15 +390,23 @@ export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: Acco
|
|
|
338
390
|
if (!NAME_PATTERN.test(name)) {
|
|
339
391
|
throw new AccountError("Account names are letters, digits, '.', '_' and '-' and start with a letter or digit");
|
|
340
392
|
}
|
|
341
|
-
|
|
342
|
-
const existing = store.load();
|
|
343
|
-
if (existing.some((a) => a.name === name)) {
|
|
393
|
+
if (store.load().some((a) => a.name === name)) {
|
|
344
394
|
throw new AccountError(
|
|
345
395
|
spec.name === undefined
|
|
346
396
|
? `An Account named "${name}" already exists; pass --name to register a second ${vendor} Account`
|
|
347
397
|
: `An Account named "${name}" already exists`,
|
|
348
398
|
);
|
|
349
399
|
}
|
|
400
|
+
return { vendor, name };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function registerAccount(store: AccountStore, fs: ConfigDirFs, spec: AccountSpec): Account {
|
|
404
|
+
// The same guards `account add --login` asks for up front, in one place: two copies of five
|
|
405
|
+
// refusals with identical messages would drift, and the one that drifted would be the one a
|
|
406
|
+
// caller had already acted on.
|
|
407
|
+
const { vendor, name } = validateAccountSpec(store, spec);
|
|
408
|
+
const cap = spec.cap ?? DEFAULT_CAP;
|
|
409
|
+
const existing = store.load();
|
|
350
410
|
|
|
351
411
|
// A registration is a fresh record, so re-adding an Account is how a human lifts a quarantine
|
|
352
412
|
// (OPS-288): the state and its streak are not carried over from anything.
|
|
@@ -383,6 +443,12 @@ export interface AccountSettings {
|
|
|
383
443
|
cap?: number;
|
|
384
444
|
maxTurns?: number;
|
|
385
445
|
stallTimeoutMs?: number;
|
|
446
|
+
thresholdPct?: number;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The threshold in force for this Account: its own, or the default. */
|
|
450
|
+
export function thresholdFor(account: Pick<Account, "thresholdPct">): number {
|
|
451
|
+
return account.thresholdPct ?? DEFAULT_THRESHOLD_PCT;
|
|
386
452
|
}
|
|
387
453
|
|
|
388
454
|
/** Changes an existing Account's scheduling limits (story 27): the cap that decides how many Slots
|
|
@@ -394,13 +460,19 @@ export function setAccountLimits(store: AccountStore, name: string, settings: Ac
|
|
|
394
460
|
if (!account) {
|
|
395
461
|
throw new AccountError(`No Account named "${name}"`);
|
|
396
462
|
}
|
|
397
|
-
if (
|
|
398
|
-
|
|
463
|
+
if (
|
|
464
|
+
settings.cap === undefined &&
|
|
465
|
+
settings.maxTurns === undefined &&
|
|
466
|
+
settings.stallTimeoutMs === undefined &&
|
|
467
|
+
settings.thresholdPct === undefined
|
|
468
|
+
) {
|
|
469
|
+
throw new AccountError("foreman account set needs at least one of --cap, --max-turns, --stall-timeout, --threshold");
|
|
399
470
|
}
|
|
400
471
|
for (const [flag, value] of [
|
|
401
472
|
["--cap", settings.cap],
|
|
402
473
|
["--max-turns", settings.maxTurns],
|
|
403
474
|
["--stall-timeout", settings.stallTimeoutMs],
|
|
475
|
+
["--threshold", settings.thresholdPct],
|
|
404
476
|
] as const) {
|
|
405
477
|
if (value !== undefined && (!Number.isInteger(value) || value < 1)) {
|
|
406
478
|
throw new AccountError(`${flag} must be a positive integer`);
|
|
@@ -414,11 +486,19 @@ export function setAccountLimits(store: AccountStore, name: string, settings: Ac
|
|
|
414
486
|
if (settings.stallTimeoutMs !== undefined && settings.stallTimeoutMs < MIN_STALL_TIMEOUT_MS) {
|
|
415
487
|
throw new AccountError(`--stall-timeout is at least ${MIN_STALL_TIMEOUT_MS}ms; below that a Worker that is only thinking is stopped as stalled`);
|
|
416
488
|
}
|
|
489
|
+
// Both ends again: a threshold so low the Account is held back most of the time, and one past
|
|
490
|
+
// what a percentage of a window can be.
|
|
491
|
+
if (settings.thresholdPct !== undefined && (settings.thresholdPct < MIN_THRESHOLD_PCT || settings.thresholdPct > MAX_THRESHOLD_PCT)) {
|
|
492
|
+
throw new AccountError(
|
|
493
|
+
`--threshold is a percentage from ${MIN_THRESHOLD_PCT} to ${MAX_THRESHOLD_PCT}; it is how much of a rate-limit window may be spent before the Account is held back`,
|
|
494
|
+
);
|
|
495
|
+
}
|
|
417
496
|
const updated: Account = {
|
|
418
497
|
...account,
|
|
419
498
|
cap: settings.cap ?? account.cap,
|
|
420
499
|
maxTurns: settings.maxTurns ?? account.maxTurns,
|
|
421
500
|
stallTimeoutMs: settings.stallTimeoutMs ?? account.stallTimeoutMs,
|
|
501
|
+
thresholdPct: settings.thresholdPct ?? account.thresholdPct,
|
|
422
502
|
};
|
|
423
503
|
store.save(existing.map((a) => (a === account ? updated : a)));
|
|
424
504
|
return updated;
|
|
@@ -442,19 +522,31 @@ export function removeAccount(store: AccountStore, name: string): Account {
|
|
|
442
522
|
* Quarantine (OPS-288) is reported ahead of a pause, and swallows it: an Account may well have been
|
|
443
523
|
* Paused before its credential died, and of the two facts only one still needs the reader to do
|
|
444
524
|
* something. It names the remedy for the same reason, since nothing lifts it by itself. */
|
|
445
|
-
export function formatAccountTable(
|
|
525
|
+
export function formatAccountTable(
|
|
526
|
+
accounts: Account[],
|
|
527
|
+
now: number = Date.now(),
|
|
528
|
+
usage?: ReadonlyMap<string, AccountUsage>,
|
|
529
|
+
): string[] {
|
|
446
530
|
const state = (a: Account): string => {
|
|
447
531
|
if (a.quarantined) return `quarantined since ${a.quarantined.at} (${printableOneLine(a.quarantined.reason)}); re-add it or: opsee foreman account resume ${a.name}`;
|
|
448
532
|
const until = pausedUntil(a, now);
|
|
449
533
|
if (until) return `paused until ${a.paused!.until}${a.paused!.reason ? ` (${oneLine(a.paused!.reason)})` : ""}`;
|
|
450
|
-
|
|
534
|
+
// A hold the Foreman chose is reported differently from a pause the vendor imposed, because the
|
|
535
|
+
// two ask different things of a reader: a pause is "Anthropic refused this", a hold is "we
|
|
536
|
+
// stopped offering it work at your threshold", which `--threshold` can lift at once.
|
|
537
|
+
const record = usage?.get(a.name);
|
|
538
|
+
const hold = thresholdHold(record, thresholdFor(a), now);
|
|
539
|
+
if (hold) return `held until ${hold.until.toISOString()} (${hold.reason}, ${describeAge(hold.observedAt, now)})`;
|
|
540
|
+
const lapsed = a.paused ? `active (pause lapsed ${a.paused.until})` : "active";
|
|
541
|
+
const left = headroom(record, now);
|
|
542
|
+
if (left.kind === "unknown") return lapsed;
|
|
543
|
+
return `${lapsed} (${left.remainingPct}% left of ${left.tightest}, ${describeAge(left.observedAt, now)})`;
|
|
451
544
|
};
|
|
452
545
|
const rows = [
|
|
453
546
|
["NAME", "VENDOR", "TYPE", "SOURCE", "CAP", "STATE"],
|
|
454
547
|
...accounts.map((a) => [a.name, a.vendor, a.type, describeSource(a), String(a.cap), state(a)]),
|
|
455
548
|
];
|
|
456
|
-
|
|
457
|
-
return rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(" "));
|
|
549
|
+
return formatTable(rows);
|
|
458
550
|
}
|
|
459
551
|
|
|
460
552
|
export function describeAccount(account: Account): string {
|
|
@@ -469,6 +561,7 @@ export function describeLimits(account: Account): string {
|
|
|
469
561
|
const perTurn = [
|
|
470
562
|
account.maxTurns === undefined ? "" : `max turns ${account.maxTurns}`,
|
|
471
563
|
account.stallTimeoutMs === undefined ? "" : `stall timeout ${account.stallTimeoutMs}ms`,
|
|
564
|
+
account.thresholdPct === undefined ? "" : `threshold ${account.thresholdPct}%`,
|
|
472
565
|
].filter(Boolean);
|
|
473
566
|
return `cap ${account.cap} (${slots})${perTurn.length ? `, ${perTurn.join(", ")}` : ""}`;
|
|
474
567
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Account } from "./account.js";
|
|
2
2
|
import { COMPLETION_REPORT_CONTRACT, completedEvent } from "./completion-report.js";
|
|
3
|
+
import type { UsageWindow } from "./usage.js";
|
|
3
4
|
import { VENDOR_CONFIG_DIR_ENV } from "./vendor.js";
|
|
4
5
|
import type { InteractiveCommand, InteractiveSessionRequest, McpServerSpec, OutputContract, TurnFailureReason, TurnHandle, TurnRequest, TurnSandbox, WorkerAdapter } from "./worker-adapter.js";
|
|
5
6
|
import {
|
|
@@ -227,6 +228,97 @@ export function resetAtFromLimitText(text: string): string | undefined {
|
|
|
227
228
|
return epoch ? new Date(Number(epoch[1]) * 1000).toISOString() : undefined;
|
|
228
229
|
}
|
|
229
230
|
|
|
231
|
+
/** One window inside `rate_limit_info.unifiedWindows`: a fraction spent and an epoch-second reset. */
|
|
232
|
+
interface RateLimitWindow {
|
|
233
|
+
utilization?: number;
|
|
234
|
+
resetsAt?: number;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `rate_limit_info` as Claude Code emits it (see the recorded fixtures in `__fixtures__/claude`).
|
|
239
|
+
*
|
|
240
|
+
* `unifiedWindows` is the valuable part and the reason no credential is needed to know an Account's
|
|
241
|
+
* headroom while it is working: the vendor sends every window it tracks, with the fraction spent
|
|
242
|
+
* and the reset, on every `rate_limit_event` — including the `allowed_warning` that fires at 90%
|
|
243
|
+
* of a window, long before anything is refused.
|
|
244
|
+
*/
|
|
245
|
+
interface RateLimitInfo {
|
|
246
|
+
status?: string;
|
|
247
|
+
resetsAt?: number;
|
|
248
|
+
rateLimitType?: string;
|
|
249
|
+
utilization?: number;
|
|
250
|
+
/** The fraction of the window whose crossing fired this warning: 0.9 in the recorded fixtures. */
|
|
251
|
+
surpassedThreshold?: number;
|
|
252
|
+
unifiedWindows?: Record<string, RateLimitWindow>;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Where the vendor's own warning fires, when the line does not carry `surpassedThreshold`. Claude
|
|
256
|
+
* Code documents the warning at 90% of a window, and the fixtures bear it out. */
|
|
257
|
+
export const WARNING_USED_PCT = 90;
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The vendor's windows as usage observations, or undefined when it reported nothing usable.
|
|
261
|
+
*
|
|
262
|
+
* Two shapes are accepted: `unifiedWindows` when it is there, and the flat
|
|
263
|
+
* `rateLimitType`/`utilization`/`resetsAt` trio otherwise, which is what an older Claude Code sends
|
|
264
|
+
* and what the flat fields still mirror for the window that triggered the event.
|
|
265
|
+
*
|
|
266
|
+
* A window is dropped unless it has both a utilization and a reset. A utilization with no reset
|
|
267
|
+
* describes no interval — nothing downstream can tell whether it is still current (`usage.ts`
|
|
268
|
+
* `isLive`) — and a reset with no utilization is not a measurement: `status: "allowed"` with no
|
|
269
|
+
* number is the vendor declining to say, and recording that as 0% would make a nearly-spent
|
|
270
|
+
* Account look fresh.
|
|
271
|
+
*/
|
|
272
|
+
export function usageWindowsFrom(info: RateLimitInfo | undefined, observedAt: string): Record<string, UsageWindow> | undefined {
|
|
273
|
+
if (!info) return undefined;
|
|
274
|
+
const source: Record<string, RateLimitWindow> =
|
|
275
|
+
info.unifiedWindows && typeof info.unifiedWindows === "object"
|
|
276
|
+
? info.unifiedWindows
|
|
277
|
+
: info.rateLimitType
|
|
278
|
+
? { [info.rateLimitType]: { utilization: info.utilization, resetsAt: info.resetsAt } }
|
|
279
|
+
: {};
|
|
280
|
+
// What a line with no utilization still tells us. `allowed_warning` means a threshold of this
|
|
281
|
+
// window was crossed, and a refusal means it is spent — both are real observations, where a plain
|
|
282
|
+
// `allowed` with no number is the vendor declining to say and must stay unrecorded.
|
|
283
|
+
//
|
|
284
|
+
// It applies to **one** window: the one `rateLimitType` names, which is the window the event is
|
|
285
|
+
// about. Spreading it across every window of `unifiedWindows` would write the five-hour warning's
|
|
286
|
+
// 90% onto a seven-day window the vendor said nothing about, and `thresholdHold` would then take
|
|
287
|
+
// the Account out of rotation until the *weekly* reset — up to seven days, on a number nobody
|
|
288
|
+
// measured. A status describes the window that raised it and no other.
|
|
289
|
+
const implied = impliedUsedPct(info);
|
|
290
|
+
const impliedFor = info.rateLimitType;
|
|
291
|
+
const windows: Record<string, UsageWindow> = {};
|
|
292
|
+
for (const [name, window] of Object.entries(source)) {
|
|
293
|
+
const measured = typeof window?.utilization === "number" && Number.isFinite(window.utilization) ? window.utilization : undefined;
|
|
294
|
+
const utilization = measured ?? (name === impliedFor ? implied : undefined);
|
|
295
|
+
const resetsAt = window?.resetsAt;
|
|
296
|
+
if (typeof utilization !== "number" || !Number.isFinite(utilization)) continue;
|
|
297
|
+
if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt)) continue;
|
|
298
|
+
windows[name] = {
|
|
299
|
+
// A fraction to a percentage, to one decimal: `0.93 * 100` is 93.00000000000001 in binary
|
|
300
|
+
// floating point, and an Account's usage should not read as that.
|
|
301
|
+
usedPct: Math.max(0, Math.min(100, Math.round(utilization * 1000) / 10)),
|
|
302
|
+
resetsAt: new Date(resetsAt * 1000).toISOString(),
|
|
303
|
+
observedAt,
|
|
304
|
+
source: "in_band",
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
return Object.keys(windows).length === 0 ? undefined : windows;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** What the status alone implies about a window the vendor sent no utilization for, as a fraction,
|
|
311
|
+
* or undefined when it implies nothing. A refusal is a spent window; a warning is one past the
|
|
312
|
+
* threshold the line names, or past the documented 90% when it names none. */
|
|
313
|
+
function impliedUsedPct(info: RateLimitInfo): number | undefined {
|
|
314
|
+
if (info.status === "allowed_warning") {
|
|
315
|
+
const named = info.surpassedThreshold;
|
|
316
|
+
return typeof named === "number" && Number.isFinite(named) && named > 0 ? named : WARNING_USED_PCT / 100;
|
|
317
|
+
}
|
|
318
|
+
// Anything that is not `allowed` and not a warning is a refusal (`handleRateLimit` treats it so).
|
|
319
|
+
return info.status !== undefined && info.status !== "allowed" ? 1 : undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
230
322
|
/** The Claude Code tool through which `--json-schema` output is delivered; its input is the report. */
|
|
231
323
|
export const STRUCTURED_OUTPUT_TOOL = "StructuredOutput";
|
|
232
324
|
|
|
@@ -235,7 +327,7 @@ interface StreamLine {
|
|
|
235
327
|
subtype?: string;
|
|
236
328
|
session_id?: string;
|
|
237
329
|
message?: { content?: unknown };
|
|
238
|
-
rate_limit_info?:
|
|
330
|
+
rate_limit_info?: RateLimitInfo;
|
|
239
331
|
is_error?: boolean;
|
|
240
332
|
result?: unknown;
|
|
241
333
|
structured_output?: unknown;
|
|
@@ -291,6 +383,13 @@ class ClaudeTurn extends ProcessTurn {
|
|
|
291
383
|
}
|
|
292
384
|
|
|
293
385
|
private handleRateLimit(info: StreamLine["rate_limit_info"]): void {
|
|
386
|
+
// Every rate-limit line is worth something, whatever its status: the vendor puts its windows
|
|
387
|
+
// and their utilizations on all of them, and an `allowed_warning` at 90% is the whole point —
|
|
388
|
+
// it is the one warning that arrives before a Task has been spent finding the limit. These
|
|
389
|
+
// numbers used to be discarded here along with the status.
|
|
390
|
+
const windows = usageWindowsFrom(info, new Date().toISOString());
|
|
391
|
+
if (windows) this.emit({ type: "usage", windows });
|
|
392
|
+
|
|
294
393
|
// `allowed` and `allowed_warning` are advisory (a warning fires at 90% of a window and the run
|
|
295
394
|
// goes on); only a refusal is reported, and it fails the turn only if the result confirms it.
|
|
296
395
|
if (!info || info.status === "allowed" || info.status === "allowed_warning") return;
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readlinkSync, rmSync, statSync, symlinkSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The shared config skeleton (the multi-account headroom design, §5).
|
|
6
|
+
*
|
|
7
|
+
* One config directory per Account is what makes multi-account work without touching a credential
|
|
8
|
+
* (claude-launcher.ts): a directory the user has logged into is a whole identity, and the vendor's
|
|
9
|
+
* own variable selects it. The cost is that everything else in there — settings, skills, commands —
|
|
10
|
+
* would live in each Account separately, so a change had to be made several times.
|
|
11
|
+
*
|
|
12
|
+
* This narrows that to history and per-account state, by symlinking the account-independent entries
|
|
13
|
+
* from one canonical directory into each Account's. Symlinks and not copies, so the canonical file
|
|
14
|
+
* stays the single thing a human edits and there is no second copy to drift.
|
|
15
|
+
*
|
|
16
|
+
* **It moves no bytes.** Every operation here is `lstat`, `readlink`, `symlink` and `rm` of a link:
|
|
17
|
+
* nothing is ever opened or read, so a credential sitting beside an allowlisted file is not read
|
|
18
|
+
* even by accident. That is what keeps this inside the boundary ADR-0013 draws, and
|
|
19
|
+
* `config-skeleton.test.ts` holds it to it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The entries that are the same whoever is logged in.
|
|
24
|
+
*
|
|
25
|
+
* An allowlist, never a denylist or a scan of the directory: a vendor that adds a file holding
|
|
26
|
+
* identity would otherwise be shared between Accounts the first time someone ran this, and nobody
|
|
27
|
+
* would find out until two Accounts were one. Anything not named here is simply not the skeleton's
|
|
28
|
+
* business.
|
|
29
|
+
*/
|
|
30
|
+
export const SKELETON_ENTRIES: readonly string[] = ["settings.json", "CLAUDE.md", "skills", "commands", "agents", ".mcp.json"];
|
|
31
|
+
|
|
32
|
+
/** Anything that looks like it carries identity, refused whatever the caller asked for. The
|
|
33
|
+
* allowlist above already excludes these; this is the second lock, for a caller passing `entries`
|
|
34
|
+
* of its own. */
|
|
35
|
+
const CREDENTIAL_SHAPED = /credential|token|keychain|auth|secret|session/i;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Files that are never shared however they are asked for, by exact name.
|
|
39
|
+
*
|
|
40
|
+
* `.claude.json` is the one worth naming: it does not read as credential-shaped, it sits right
|
|
41
|
+
* beside the entries that *are* shared, and it carries the account's own identity and project
|
|
42
|
+
* state. Linking it between two Accounts would quietly make them one Account — the exact failure
|
|
43
|
+
* this whole design exists to avoid — and nothing above would have refused it.
|
|
44
|
+
*/
|
|
45
|
+
const NEVER_SHARED: readonly string[] = [".claude.json", ".credentials.json"];
|
|
46
|
+
|
|
47
|
+
export interface SkeletonRequest {
|
|
48
|
+
/** The directory the shared files really live in — normally the user's own `~/.claude`. */
|
|
49
|
+
canonical: string;
|
|
50
|
+
/** The Account config directories to link them into. */
|
|
51
|
+
targets: readonly string[];
|
|
52
|
+
/** Defaults to `SKELETON_ENTRIES`. */
|
|
53
|
+
entries?: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type SkeletonOutcome =
|
|
57
|
+
/** A new link was made. */
|
|
58
|
+
| "linked"
|
|
59
|
+
/** A link that pointed elsewhere now points at the canonical entry. */
|
|
60
|
+
| "relinked"
|
|
61
|
+
/** The right link was already there. */
|
|
62
|
+
| "already"
|
|
63
|
+
/** The canonical directory does not have this entry, so there is nothing to share. */
|
|
64
|
+
| "missing"
|
|
65
|
+
/** The target directory does not exist; a config directory is the vendor's login to create. */
|
|
66
|
+
| "no-target"
|
|
67
|
+
/** The target *is* the canonical directory. */
|
|
68
|
+
| "self"
|
|
69
|
+
/** Deliberately not done, with a reason: a real file in the way, or a credential-shaped name. */
|
|
70
|
+
| "refused";
|
|
71
|
+
|
|
72
|
+
export interface SkeletonResult {
|
|
73
|
+
target: string;
|
|
74
|
+
entry: string;
|
|
75
|
+
outcome: SkeletonOutcome;
|
|
76
|
+
note?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function syncSkeleton(request: SkeletonRequest): SkeletonResult[] {
|
|
80
|
+
const canonical = resolve(request.canonical);
|
|
81
|
+
const entries = request.entries ?? SKELETON_ENTRIES;
|
|
82
|
+
const results: SkeletonResult[] = [];
|
|
83
|
+
|
|
84
|
+
for (const rawTarget of request.targets) {
|
|
85
|
+
const target = resolve(rawTarget);
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const refusal = unsafe(entry);
|
|
88
|
+
if (refusal) {
|
|
89
|
+
results.push({ target, entry, outcome: "refused", note: refusal });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (target === canonical) {
|
|
93
|
+
results.push({ target, entry, outcome: "self", note: "this is the canonical directory; its files are the originals" });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!isDirectory(target)) {
|
|
97
|
+
results.push({ target, entry, outcome: "no-target", note: "no such config directory; the vendor's own login creates one" });
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const source = join(canonical, entry);
|
|
101
|
+
if (!existsSync(source)) {
|
|
102
|
+
results.push({ target, entry, outcome: "missing" });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
results.push(link(source, join(target, entry), target, entry));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Why this entry will not be touched, or undefined when it is fine. */
|
|
112
|
+
function unsafe(entry: string): string | undefined {
|
|
113
|
+
if (entry === "" || entry === "." || entry === "..") return "not a file name";
|
|
114
|
+
// One path segment only. An entry with a separator in it could place a link anywhere, and an
|
|
115
|
+
// entry with `..` could reach out of the config directory entirely.
|
|
116
|
+
if (entry.includes("/") || entry.includes("\\") || entry.includes("..")) return "an entry is one file name, not a path";
|
|
117
|
+
if (NEVER_SHARED.includes(entry)) return `${entry} carries this Account's own identity, and is never shared between Accounts`;
|
|
118
|
+
if (CREDENTIAL_SHAPED.test(entry)) return "this is credential-shaped, and a credential is never shared between Accounts";
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isDirectory(path: string): boolean {
|
|
123
|
+
try {
|
|
124
|
+
return statSync(path).isDirectory();
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Makes `at` a symlink to `source`, or explains why it was left alone. */
|
|
131
|
+
function link(source: string, at: string, target: string, entry: string): SkeletonResult {
|
|
132
|
+
let existing: ReturnType<typeof lstatSync> | undefined;
|
|
133
|
+
try {
|
|
134
|
+
existing = lstatSync(at);
|
|
135
|
+
} catch {
|
|
136
|
+
existing = undefined;
|
|
137
|
+
}
|
|
138
|
+
if (existing?.isSymbolicLink()) {
|
|
139
|
+
let points: string | undefined;
|
|
140
|
+
try {
|
|
141
|
+
points = readlinkSync(at);
|
|
142
|
+
} catch {
|
|
143
|
+
points = undefined;
|
|
144
|
+
}
|
|
145
|
+
if (points !== undefined && resolve(points) === source) return { target, entry, outcome: "already" };
|
|
146
|
+
// Only ever a symlink is removed here, and only after `lstat` has said it is one — so nothing
|
|
147
|
+
// a person wrote can be lost by this branch.
|
|
148
|
+
rmSync(at);
|
|
149
|
+
symlinkSync(source, at);
|
|
150
|
+
return { target, entry, outcome: "relinked", note: `it pointed at ${points ?? "somewhere unreadable"}` };
|
|
151
|
+
}
|
|
152
|
+
if (existing) {
|
|
153
|
+
return {
|
|
154
|
+
target,
|
|
155
|
+
entry,
|
|
156
|
+
outcome: "refused",
|
|
157
|
+
note: `this Account has its own ${entry} here; move or delete it to share the canonical one`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
symlinkSync(source, at);
|
|
161
|
+
return { target, entry, outcome: "linked" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One line per result, for the command to print. Ordered as the sync happened. */
|
|
165
|
+
export function formatSkeletonResults(results: readonly SkeletonResult[]): string[] {
|
|
166
|
+
return results.map((r) => ` ${r.outcome.padEnd(9)} ${r.entry}${r.outcome === "linked" || r.outcome === "relinked" || r.outcome === "already" ? ` in ${r.target}` : r.note ? ` — ${r.note}` : ""}`);
|
|
167
|
+
}
|