agent-shim 7.7.0 → 7.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 +4 -2
- package/dist/cli.cjs +3983 -3955
- package/dist/index.cjs +3214 -173
- package/dist/index.mjs +3136 -158
- package/dist/types/checkReport.d.ts +154 -0
- package/dist/types/claudeShim.d.ts +114 -0
- package/dist/types/cli/bool.d.ts +8 -0
- package/dist/types/cli/commandDeps.d.ts +28 -0
- package/dist/types/cli/envBool.d.ts +11 -0
- package/dist/types/codex/http.d.ts +15 -0
- package/dist/types/configProfilesStore.d.ts +61 -0
- package/dist/types/configure.d.ts +170 -0
- package/dist/types/credential.d.ts +136 -0
- package/dist/types/credentialCache.d.ts +46 -0
- package/dist/types/directoryRulesStore.d.ts +43 -0
- package/dist/types/doctorReport.d.ts +117 -0
- package/dist/types/frontdoor/custody.d.ts +32 -0
- package/dist/types/frontdoor/headroomHop.d.ts +22 -0
- package/dist/types/frontdoor/pipeline.d.ts +109 -0
- package/dist/types/frontdoor/server.d.ts +44 -0
- package/dist/types/headroom/headers.d.ts +11 -0
- package/dist/types/identityStore.d.ts +85 -0
- package/dist/types/index.d.ts +12 -1
- package/dist/types/launcher/cascade.d.ts +42 -0
- package/dist/types/launcher/flags.d.ts +96 -0
- package/dist/types/launcher/identity.d.ts +95 -0
- package/dist/types/launcher/pool.d.ts +44 -0
- package/dist/types/pathNorm.d.ts +14 -0
- package/dist/types/poolPickReport.d.ts +44 -0
- package/dist/types/poolStore.d.ts +18 -0
- package/dist/types/providersStore.d.ts +137 -0
- package/dist/types/realPorts.d.ts +102 -0
- package/dist/types/usage/middleware.d.ts +17 -0
- package/dist/types/usage/pick.d.ts +62 -0
- package/dist/types/usage/plan.d.ts +18 -0
- package/dist/types/usage/poolPick.d.ts +31 -0
- package/dist/types/usage/preflight.d.ts +20 -0
- package/dist/types/usage/rateLimit.d.ts +25 -0
- package/dist/types/usage/scan.d.ts +24 -0
- package/dist/types/usage/store.d.ts +43 -0
- package/dist/types/versionDiscovery.d.ts +47 -0
- package/package.json +1 -1
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CredentialPort } from "./credential";
|
|
2
|
+
import { type DiscoveredClaudeBinary } from "./versionDiscovery";
|
|
3
|
+
import type { LayoutPaths } from "./paths";
|
|
4
|
+
import type { FarmFs, FsPort, HeadroomPort, LogPort, ProcPort, RunPort, SpawnPort } from "./launcher/ports";
|
|
5
|
+
/** The real, `node:fs`-backed `FsPort` used by the actual `claude` launcher — never used in tests. */
|
|
6
|
+
export declare const realFsPort: FsPort;
|
|
7
|
+
export declare const realFarmFs: FarmFs;
|
|
8
|
+
/**
|
|
9
|
+
* Blocks the current thread for `ms` milliseconds.
|
|
10
|
+
*
|
|
11
|
+
* The launcher is synchronous end to end, right through to `spawnSync`, so waiting on another process's identity lock cannot be done with a promise. `Atomics.wait` on a private buffer is the one way to sleep synchronously without burning the CPU in a spin loop.
|
|
12
|
+
*/
|
|
13
|
+
export declare function realSleepSync(ms: number): void;
|
|
14
|
+
/**
|
|
15
|
+
* Whether `pid` is a live, schedulable process: signal-0 alive AND not a zombie. This is the liveness notion every headroom coordination decision must use, because a defunct daemon holds no port and a defunct supervisor will never write state, yet both still "exist" as far as signal 0 is concerned.
|
|
16
|
+
*/
|
|
17
|
+
export declare function realIsProcessRunning(pid: number): boolean;
|
|
18
|
+
/** The real `RunPort`, used for auxiliary commands whose output this process needs to read — git branch detection for `when: { branch }` conditions, and `check.ts`'s macOS Keychain lookup. */
|
|
19
|
+
export declare const realRunPort: RunPort;
|
|
20
|
+
/**
|
|
21
|
+
* The real `CredentialPort`. A secret file's mode is reported only where POSIX permission bits exist (not on Windows, whose ACLs Node's `stat` does not expose). A command runs with no shell under its timeout; a non-interactive one gets no stdin and has its stderr captured for the error message, while an interactive one inherits the terminal's stdin and stderr so its prompt reaches a person. stdout is always captured, since it is the token.
|
|
22
|
+
*/
|
|
23
|
+
export declare const realCredentialPort: CredentialPort;
|
|
24
|
+
/** Reads the git branch checked out at `cwd`, and whether the repository is in detached-HEAD state. Both undefined when `cwd` is not in a repository at all. */
|
|
25
|
+
export declare function resolveGitBranch(run: RunPort, cwd: string): {
|
|
26
|
+
branch?: string;
|
|
27
|
+
branchDetached?: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The spawn options every background daemon agent-shim starts shares. `detached: true` puts the child in a new session with no controlling terminal, so closing the terminal a launch started from (SIGHUP to that terminal's session) never reaches it: a second session still using the daemon keeps working after the first one's terminal is gone. Standard input is closed and both output streams go to the daemon's log file.
|
|
31
|
+
*/
|
|
32
|
+
export declare function detachedDaemonSpawnOptions(logFd: number, env: NodeJS.ProcessEnv): {
|
|
33
|
+
readonly detached: true;
|
|
34
|
+
readonly stdio: ["ignore", number, number];
|
|
35
|
+
readonly env: NodeJS.ProcessEnv;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Spawns a detached supervisor: a background copy of this very executable running a hidden internal subcommand, its output appended to `logPath`, unref'd so the launcher never waits on it. `AGENT_SHIM_HOME` is passed explicitly so the supervisor lands on the same root as its spawner even when the launcher was started with the variable set only for itself.
|
|
39
|
+
*/
|
|
40
|
+
export declare function spawnDetachedSupervisor(paths: LayoutPaths, subcommand: string, logPath: string): number;
|
|
41
|
+
/**
|
|
42
|
+
* Whether nothing is currently listening on one specific loopback port: binds it on 127.0.0.1 and releases, so the headroom supervisor can decide whether its sticky `lastPort` is still reusable. The same bind-and-release technique the supervisor's `freePort` uses, applied to a number the caller already cares about rather than asking the OS to pick one.
|
|
43
|
+
*/
|
|
44
|
+
export declare function realIsPortFree(port: number): Promise<boolean>;
|
|
45
|
+
/**
|
|
46
|
+
* The real `HeadroomPort` for one launch: `ensure` runs the lock-and-poll coordination against the real filesystem and process table and derives the per-project identity from the real git repository containing the working directory; `release` removes this launcher's session-registry entry.
|
|
47
|
+
*/
|
|
48
|
+
export declare function realHeadroomPort(paths: LayoutPaths): HeadroomPort;
|
|
49
|
+
/** The real `SpawnPort`, backed by `node:child_process`'s `spawnSync` — the Node equivalent of the legacy bash tool's `exec`. */
|
|
50
|
+
export declare const realSpawnPort: SpawnPort;
|
|
51
|
+
/** The real `ProcPort`, reading the actual process environment and logical CLI argv (`process.argv.slice(2)`). */
|
|
52
|
+
export declare const realProcPort: ProcPort;
|
|
53
|
+
/** The real `LogPort`, writing info/warn to stdout and errors to stderr. */
|
|
54
|
+
export declare const realLogPort: LogPort;
|
|
55
|
+
/**
|
|
56
|
+
* The pure decision logic behind `findExecutableInDir`, taking `platform`/`pathext`/the mode-stat as explicit parameters so it's unit-testable against fake Windows/POSIX environments without touching real process globals or a real filesystem — this is exactly the kind of platform-dependent logic that's easy to get subtly wrong (confirmed: it was, twice, across two failed release attempts before this was extracted and actually tested).
|
|
57
|
+
*
|
|
58
|
+
* `statFileMode` returns the file's POSIX mode bits if `candidate` exists as a regular file, or `undefined` otherwise (ENOENT collapsed to `undefined`, every other error still propagated by the real implementation).
|
|
59
|
+
*
|
|
60
|
+
* On Windows, this can't rely on `fs.Stats.mode`'s execute bits at all (see the comment above `windowsPathExtensions`), so it instead mirrors what a real shell does resolving a bare command name: try `name` as given if it already carries a recognised extension (e.g. a `.exe` filename passed in explicitly, as `resolveOwnExecutablePath`'s Scoop-shim redirect does), otherwise try appending each `PATHEXT` extension in turn and return the first that exists as a file. Everywhere else, a file counts as executable only when its POSIX mode bits actually say so.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolveExecutableCandidate(dir: string, name: string, env: {
|
|
63
|
+
readonly platform: string;
|
|
64
|
+
readonly pathext: string | undefined;
|
|
65
|
+
readonly statFileMode: (candidate: string) => number | undefined;
|
|
66
|
+
}): string | undefined;
|
|
67
|
+
/** Looks for a file literally named `name` inside `dir` and confirms it's runnable. Exported so `src/claudeShim.ts`'s PATH-shadow and shim-redirect checks can reuse the exact same semantics `discoverClaudeBinary`'s own PATH fallback already uses, rather than shelling out to a shell builtin like `command -v`. Real-wired convenience over `resolveExecutableCandidate` — see that function for the actual decision logic and its own tests. */
|
|
68
|
+
export declare function findExecutableInDir(dir: string, name: string): string | undefined;
|
|
69
|
+
/**
|
|
70
|
+
* Resolves the absolute path to the currently-running agent-shim/claude executable from the raw process/env facts a call site would otherwise read directly off `process` — passed explicitly so this is unit-testable without touching real process globals.
|
|
71
|
+
*
|
|
72
|
+
* This exists because `process.argv[1]` is not a reliable "path to my own executable" for a Node single-executable-application (SEA) binary the way it is for an ordinary Node script. Per Node's own SEA documentation, a SEA binary's `argv[1]` merely echoes back whatever string appeared on the command line — verbatim, with no path or PATH resolution applied by Node or the OS — whereas a shebang-invoked script (the npm/plain-Node case) genuinely receives its resolved script path there, because the kernel's shebang handling substitutes the shell's already-PATH-resolved pathname before Node ever starts. Concretely: typing the bare word `agent-shim` at a shell prompt produces `argv[1] === "agent-shim"` for a SEA binary (no directory component at all), which previously got fed straight into `fs.realpathSync`/`path.dirname` as if it were a real path — resolving relative to `process.cwd()` instead of wherever PATH actually found the binary, and throwing ENOENT the moment cwd didn't happen to contain a same-named file. Confirmed via a minimal instrumented SEA binary reproducing exactly this, and matching the exact failure every release verification job hit once invoking `agent-shim`/`claude` as a bare PATH-resolved command rather than by absolute path.
|
|
73
|
+
*
|
|
74
|
+
* A second, separate wrinkle: some install channels invoke this binary through a re-exec wrapper rather than running it directly. Scoop's own shim (`~/scoop/shims/agent-shim.exe`) is a compiled proxy (confirmed from its source: https://github.com/ScoopInstaller/Shim/blob/main/cs/shim.cs) that calls `CreateProcessW(null, cmd, ...)` with the real target's absolute path as the first token of `cmd` — per the Win32 `CreateProcess` contract, passing `lpApplicationName = null` means that first token becomes both the module launched *and* what the child receives as its own command line — so this process ends up seeing the real target's absolute path, living in a versioned app directory Scoop deliberately keeps off PATH, not the shim's own PATH-visible location. Placing the new `claude` alongside that path (as the Homebrew/direct-invocation case correctly does) would create a file PATH can never find. Confirmed against a real Scoop install: `agent-shim shim enable` placed `claude.exe` next to the real target in the app directory, and the immediately following `claude --version` failed with "not recognized" because that directory was never on PATH.
|
|
75
|
+
*
|
|
76
|
+
* The redirect-if-not-on-PATH check below applies uniformly to whichever candidate we end up with — whether derived from a path-shaped `argv1`, or from the `execPath` fallback (`argv1` undefined) — since it's unconfirmed whether a Windows SEA binary duplicates `argv[0]` into `argv[1]` the same way the POSIX build does; treating both sources identically is strictly more robust either way and regresses nothing already confirmed working.
|
|
77
|
+
*
|
|
78
|
+
* The algorithm: if the raw candidate is a bare word with no path separator, it can only have been found via PATH lookup in the first place (Node/the OS applies no resolution to it at all), so search PATH ourselves for the first directory containing an executable of that name, reconstructing exactly what the shell already did. Otherwise, resolve it (or the `execPath` fallback) against cwd, then check whether the *resulting directory* is itself on PATH: if so, use it directly (the Homebrew/direct-invocation case — never realpath'd, since a package manager's PATH-visible entry is often a symlink elsewhere, e.g. Homebrew's `/opt/homebrew/bin/agent-shim` pointing at its own Cellar keg, and callers need that PATH-visible location, not the dereferenced target). If that directory is *not* on PATH (the Scoop re-exec case above), search PATH for a separately-installed entry sharing our own invoked basename before falling back to the direct candidate.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveOwnExecutablePath(env: {
|
|
81
|
+
readonly argv1: string | undefined;
|
|
82
|
+
readonly execPath: string;
|
|
83
|
+
readonly cwd: string;
|
|
84
|
+
readonly pathDirs: readonly string[];
|
|
85
|
+
readonly findExecutableInDir: (dir: string, name: string) => string | undefined;
|
|
86
|
+
}): string;
|
|
87
|
+
/** Real-wired convenience over `resolveOwnExecutablePath`, reading the actual process globals — this is what every real call site (`cli.ts`, `doctor.ts`, `claudeShim.ts`'s command wiring) should use instead of reading `process.argv[1]`/`process.execPath` directly. */
|
|
88
|
+
export declare function realOwnExecutablePath(): string;
|
|
89
|
+
/**
|
|
90
|
+
* Resolves the path to the actual file whose *content* is this running process — never a PATH-visible location, which can differ from the real content entirely (Scoop's own shim, a compiled proxy binary, sits at the PATH-visible location while the genuine agent-shim binary lives elsewhere; see `resolveOwnExecutablePath`'s own doc comment for the full mechanism). This is what `enableClaudeShim`/`disableClaudeShim` must hardlink/copy from and compare inodes against — using `resolveOwnExecutablePath`'s redirected PATH-visible location for this would hardlink Scoop's generic shim proxy itself, not agent-shim's real content, which is exactly the bug this function exists to avoid (confirmed against a real Scoop install: `shim enable` correctly placed `claude.exe` in `shims/`, but invoking it failed with Scoop's own "Cannot open shim file for read" error, because the hardlink's source was Scoop's proxy, paired via a `.shim` config file keyed to its *original* filename).
|
|
91
|
+
*
|
|
92
|
+
* `node:sea`'s `isSea()` is the correct, documented way to distinguish the two operating modes this tool ships in, rather than inferring it from `argv1`'s shape: for a single-executable-application build, `process.execPath` is always the real, fully-resolved running binary (confirmed: Windows `CreateProcess` with `lpApplicationName = null` still resolves to the genuine target file, the same one `execPath` reports, regardless of Scoop's separate PATH-visible proxy). For a plain Node script (the npm-published bundle), `execPath` would instead point at the Node interpreter itself — useless — so `argv1` (the shebang-resolved script path, reliable in this mode) is used instead.
|
|
93
|
+
*/
|
|
94
|
+
export declare function resolveContentSourcePath(env: {
|
|
95
|
+
readonly isSea: boolean;
|
|
96
|
+
readonly execPath: string;
|
|
97
|
+
readonly argv1: string | undefined;
|
|
98
|
+
}): string;
|
|
99
|
+
/** Real-wired convenience over `resolveContentSourcePath`. */
|
|
100
|
+
export declare function realContentSourcePath(): string;
|
|
101
|
+
/** Builds the real `resolveClaudeBinary` callback `runLauncher` needs: scans the real versions directory, then falls back to a real PATH search, excluding this tool's own install directory. */
|
|
102
|
+
export declare function realResolveClaudeBinary(ownInstallDirs: readonly string[]): () => DiscoveredClaudeBinary;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ResponseObserver } from "../frontdoor/pipeline";
|
|
2
|
+
import { type UsageRecord } from "./schema";
|
|
3
|
+
/** The provider name an OAuth session's requests are recorded under: they go to Claude Code's own API. */
|
|
4
|
+
export declare const ANTHROPIC_PROVIDER = "anthropic";
|
|
5
|
+
/** Everything the usage middleware depends on, injected so it runs against fakes in tests. */
|
|
6
|
+
export interface UsageMiddlewareDeps {
|
|
7
|
+
/** Persists one finished request's record. It may throw; the middleware catches and logs, so a store failure never reaches a request. */
|
|
8
|
+
readonly record: (record: UsageRecord) => void;
|
|
9
|
+
/** Runs work after the current turn of the event loop, so recording never sits between a route's `end` and whatever the route does next (production: `setImmediate`). */
|
|
10
|
+
readonly defer: (task: () => void) => void;
|
|
11
|
+
readonly now: () => number;
|
|
12
|
+
readonly log: (line: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The usage-tracking response middleware: registered at the front door's hook point, it records one metadata-only `UsageRecord` per routed response once its body has finished. The head gives the status, latency and rate-limit headers; the body, scanned incrementally as the client receives it (decompressing a copy when the upstream compressed it), gives the model and token usage. The response the client receives is never touched: the middleware sees each chunk only after the socket has it, keeps no body, and does its recording on a deferred turn. Every failure (a scanner, a decompressor, the store) is caught and logged, never thrown into the request.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createUsageMiddleware(deps: UsageMiddlewareDeps): ResponseObserver;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type PlanClass } from "./plan";
|
|
2
|
+
import type { AccountMetadata, UsageRecord, UsageSnapshot } from "./schema";
|
|
3
|
+
/** The lengths of Anthropic's two subscription windows, used when a window reported no reset time of its own. */
|
|
4
|
+
export declare const FIVE_HOUR_WINDOW_MS: number;
|
|
5
|
+
/** Anthropic's longest prompt-cache lifetime (the one-hour tier): a conversation resumed within it still has a warm cache on the account that served it, and the cache is per organisation, so it is lost by switching. */
|
|
6
|
+
export declare const PROMPT_CACHE_TTL_MS: number;
|
|
7
|
+
/** The state a member's ranking was built from. */
|
|
8
|
+
export interface PoolMember {
|
|
9
|
+
readonly identity: string;
|
|
10
|
+
/** The identity's usage snapshot, when it has one. */
|
|
11
|
+
readonly snapshot?: UsageSnapshot;
|
|
12
|
+
/** Why the member's recorded state could not be read (a corrupt or newer-schema snapshot, an unreadable login file), when that is the case. */
|
|
13
|
+
readonly readError?: string;
|
|
14
|
+
readonly account?: AccountMetadata;
|
|
15
|
+
/** The member's usage-log records from the current five-hour window on. */
|
|
16
|
+
readonly records: readonly UsageRecord[];
|
|
17
|
+
}
|
|
18
|
+
/** The member last picked for this directory, kept so a conversation stays on the account whose prompt cache is warm. */
|
|
19
|
+
export interface StickyPick {
|
|
20
|
+
readonly identity: string;
|
|
21
|
+
readonly at: string;
|
|
22
|
+
}
|
|
23
|
+
export interface RankPoolInput {
|
|
24
|
+
readonly members: readonly PoolMember[];
|
|
25
|
+
readonly nowMs: number;
|
|
26
|
+
readonly sticky?: StickyPick;
|
|
27
|
+
/** True when the launch continues or resumes a conversation, which belongs on the account it started on whatever the cache lifetime. */
|
|
28
|
+
readonly resuming: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** In pick order. `scored` has usable quota data; `unknown` has none (never recorded, or unreadable); `pay-per-use` bills by use instead of drawing on a plan; `ineligible` is refused right now. */
|
|
31
|
+
type CandidateClass = "scored" | "unknown" | "pay-per-use" | "ineligible";
|
|
32
|
+
export interface Candidate {
|
|
33
|
+
readonly identity: string;
|
|
34
|
+
readonly class: CandidateClass;
|
|
35
|
+
/** Plan-size-weighted remaining quota per hour until its reset: higher is more wasted if left unused. Present for `scored` only. */
|
|
36
|
+
readonly score?: number;
|
|
37
|
+
/** False when the five-hour window would run dry, at the observed pace, before it resets. A `scored` candidate that is not feasible ranks below every feasible one. */
|
|
38
|
+
readonly feasible: boolean;
|
|
39
|
+
/** When an `ineligible` candidate can be used again. */
|
|
40
|
+
readonly blockedUntilMs?: number;
|
|
41
|
+
readonly plan: PlanClass;
|
|
42
|
+
/** Human-readable facts behind the class and score, in the order they matter. */
|
|
43
|
+
readonly reasons: readonly string[];
|
|
44
|
+
}
|
|
45
|
+
export interface PoolRanking {
|
|
46
|
+
/** Every member, best first. */
|
|
47
|
+
readonly candidates: readonly Candidate[];
|
|
48
|
+
/** The member to launch on: the best one that is not refused. Undefined when every member is refused. */
|
|
49
|
+
readonly pick?: Candidate;
|
|
50
|
+
/** When nothing can be picked, the soonest any member comes back. */
|
|
51
|
+
readonly earliestReturn?: {
|
|
52
|
+
readonly identity: string;
|
|
53
|
+
readonly atMs: number;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Ranks `members` for a launch.
|
|
58
|
+
*
|
|
59
|
+
* A member is `ineligible` while a window of its plan, or a refusal it last hit, still binds; it is `unknown` without recorded quota, `pay-per-use` when its usage bills by use, and otherwise `scored`. Scored members order by how much plan-size-weighted quota would expire unused per hour of runway, with any whose five-hour window would run dry at the observed pace (their own, or the person's pace on another member rescaled by plan size) placed behind those that would not. The member last picked for this directory then moves to the front if it is still usable and either the launch resumes a conversation or its prompt cache is still warm.
|
|
60
|
+
*/
|
|
61
|
+
export declare function rankPool(input: RankPoolInput): PoolRanking;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AccountMetadata } from "./schema";
|
|
2
|
+
/** What an account's plan means for picking it: a subscription with a capacity relative to the smallest paid tier, or an account billed by use. */
|
|
3
|
+
export type PlanClass = {
|
|
4
|
+
readonly kind: "subscription";
|
|
5
|
+
/** The plan's size as a multiple of the smallest subscription tier (Max 5x is 5, Max 20x is 20). */
|
|
6
|
+
readonly capacity: number;
|
|
7
|
+
/** False when the tier string named no multiplier and no tier this project knows, so `capacity` is the neutral 1 rather than a reading. */
|
|
8
|
+
readonly recognised: boolean;
|
|
9
|
+
/** The tier string the classification rests on, when the account had one. */
|
|
10
|
+
readonly tier?: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly kind: "pay-per-use";
|
|
13
|
+
readonly tier?: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Classifies an account from the rate-limit tier of its stored login (`organizationRateLimitTier`, which decides the organisation's limits, then `userRateLimitTier`). The tier strings are free text on Anthropic's side, so only the shapes seen are read: a trailing `<n>x` is that multiple, a bare `pro` is 1, a `zero` tier is pay-per-use. Anything else is a subscription of unrecognised size, counted as 1 and marked so the ranking can say it guessed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function planOf(account: AccountMetadata | undefined): PlanClass;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { FarmFs } from "../launcher/ports";
|
|
2
|
+
import type { LayoutPaths } from "../paths";
|
|
3
|
+
import { type PoolMember, type PoolRanking, type StickyPick } from "./pick";
|
|
4
|
+
import { type UsageReadFs } from "./read";
|
|
5
|
+
/**
|
|
6
|
+
* The store-backed side of pool picking: loads each member's recorded state and ranks it with `rankPool`, and keeps the per-directory record of the last pick. The launcher and `agent-shim pool pick` both go through `rankPoolFromStore`, so the command shows exactly what a launch would choose.
|
|
7
|
+
*/
|
|
8
|
+
/** The reads a pool ranking needs: snapshots, the log and each member's login file. */
|
|
9
|
+
export type PoolPickFs = UsageReadFs & Pick<FarmFs, "readFileUtf8">;
|
|
10
|
+
type PoolPickPaths = Pick<LayoutPaths, "identitiesDir" | "usageLogDir" | "usageSnapshotsDir">;
|
|
11
|
+
/** Reads each member's snapshot, login metadata and the part of the log that reaches into the current five-hour window. A member whose state cannot be read is carried with the reason instead of failing the pick. */
|
|
12
|
+
export declare function loadPoolMembers(fs: PoolPickFs, paths: PoolPickPaths, identities: readonly string[], nowMs: number): readonly PoolMember[];
|
|
13
|
+
/** Ranks `identities` from what the usage store recorded, as of `nowMs`. */
|
|
14
|
+
export declare function rankPoolFromStore(params: Readonly<{
|
|
15
|
+
fs: PoolPickFs;
|
|
16
|
+
paths: PoolPickPaths;
|
|
17
|
+
identities: readonly string[];
|
|
18
|
+
nowMs: number;
|
|
19
|
+
sticky?: StickyPick;
|
|
20
|
+
resuming: boolean;
|
|
21
|
+
}>): PoolRanking;
|
|
22
|
+
/** The identity last picked from a pool for `directory`, with a note when the picks file was unreadable (a hint lost, never a launch blocked). */
|
|
23
|
+
export declare function readStickyPick(fs: Pick<FarmFs, "readFileUtf8">, picksFile: string, directory: string): {
|
|
24
|
+
readonly sticky?: StickyPick;
|
|
25
|
+
readonly problem?: string;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Records that `identity` was picked for `directory` at `nowMs`, dropping entries older than the usage log's retention so the file stays bounded by what the rest of the store keeps. The write is a whole-file atomic replace; two launches finishing together can lose one entry, which only costs a cache hint.
|
|
29
|
+
*/
|
|
30
|
+
export declare function recordStickyPick(fs: Pick<FarmFs, "readFileUtf8" | "writeFilePrivate" | "mkdirPrivate">, picksFile: string, directory: string, identity: string, nowMs: number): void;
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { QuotaWindow, UsageSnapshot } from "./schema";
|
|
2
|
+
/** A duration as its largest whole unit, such as `3h` or `2d`. */
|
|
3
|
+
export declare function formatAge(ms: number): string;
|
|
4
|
+
/** A quota window as it stands at a given instant. */
|
|
5
|
+
export interface EffectiveWindow {
|
|
6
|
+
/** True when the window's reset time has passed, so the recorded observation describes a window that no longer exists. */
|
|
7
|
+
readonly reset: boolean;
|
|
8
|
+
/** The fraction used: 0 for a reset window, otherwise what was last observed (a lower bound, since utilisation only rises within a window), or undefined when none was reported. */
|
|
9
|
+
readonly utilization?: number;
|
|
10
|
+
/** The reset instant, for a window that has not reset yet and reported one. */
|
|
11
|
+
readonly resetsAtMs?: number;
|
|
12
|
+
/** The window's own status, for a window that has not reset yet. */
|
|
13
|
+
readonly status?: string;
|
|
14
|
+
}
|
|
15
|
+
/** Reads a recorded window at `nowMs`: a window whose reset has passed is empty and carries no status, so every consumer agrees on what an old observation still means. */
|
|
16
|
+
export declare function effectiveWindow(window: Readonly<QuotaWindow>, nowMs: number): EffectiveWindow;
|
|
17
|
+
/**
|
|
18
|
+
* The launch-time warnings for an identity's quota, from the last rate-limit state the front door recorded for the provider this launch will use (`anthropic` for an OAuth launch). A window is reported when the API itself last marked it `allowed_warning` or `rejected`, and only while that window has not reset since: a reset time in the past means the observation describes a window that no longer exists. Each warning states how old the observation is, because the snapshot only changes when a request goes through the front door. Warnings never block a launch.
|
|
19
|
+
*/
|
|
20
|
+
export declare function quotaWarnings(snapshot: UsageSnapshot | undefined, provider: string, nowMs: number): string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type LimitClassification, type UnifiedRateLimit } from "./schema";
|
|
2
|
+
/** What a refused response's error body said, as far as classification needs: its machine-readable type and code, and its message, which is read here and never stored. */
|
|
3
|
+
export interface ErrorInfo {
|
|
4
|
+
readonly type?: string;
|
|
5
|
+
readonly code?: string;
|
|
6
|
+
readonly message?: string;
|
|
7
|
+
}
|
|
8
|
+
/** Keeps a response's rate-limit headers (see `isRateLimitHeader`), names lowercased; undefined when there are none. */
|
|
9
|
+
export declare function rateLimitHeadersOf(headers: Readonly<Record<string, string>>): Record<string, string> | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Parses Anthropic's unified subscription rate-limit headers, from `rateLimitHeadersOf`'s lowercased map. These were captured from a real `api.anthropic.com` response to an OAuth (subscription) session through the front door: `-status`, `-5h-status`, `-5h-utilization` (a fraction), `-5h-reset` (epoch seconds), the same three for `7d`, `-representative-claim`, `-reset`, `-overage-status`, `-overage-disabled-reason`, `-fallback` and `-fallback-percentage`. Undefined when the response carried none of them (an API-key or third-party provider response).
|
|
12
|
+
*/
|
|
13
|
+
export declare function parseUnifiedRateLimit(headers: Readonly<Record<string, string>>): UnifiedRateLimit | undefined;
|
|
14
|
+
/**
|
|
15
|
+
* Classifies a refused response as a short rate limit or an exhausted quota, the distinction the credential fallback acts on (a short limit retries the same credential once its wait has passed, an exhausted quota moves to the next one). Only a 429 or a 402 is classified; anything else is undefined.
|
|
16
|
+
*
|
|
17
|
+
* An exhausted quota is any of: a 402 (OpenRouter's "insufficient credits"); Anthropic's unified status `rejected` with no extra usage to fall back on (`overage-status` absent or `rejected`), the rule Claude Code itself applies before it waits for the plan's reset; an error type or code in `QUOTA_EXHAUSTED_ERRORS`; or an error message naming a usage limit. Every other 429 is a short rate limit. `resetAt` comes from the unified reset header, `retryAfterSeconds` from `retry-after`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function classifyLimit(params: {
|
|
20
|
+
readonly status: number;
|
|
21
|
+
/** The response's rate-limit headers, as `rateLimitHeadersOf` returns them. */
|
|
22
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
23
|
+
readonly error: ErrorInfo | undefined;
|
|
24
|
+
readonly nowMs: number;
|
|
25
|
+
}): LimitClassification | undefined;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ErrorInfo } from "./rateLimit";
|
|
2
|
+
import type { TokenUsage } from "./schema";
|
|
3
|
+
/**
|
|
4
|
+
* Incremental scanners that pull usage metadata out of a response body as it streams past, without ever holding the body: the model, the `usage` token counts, and an error's type, code and message. Content (text deltas, tool input, message content) is skipped character by character and never accumulated, so memory stays bounded by the metadata itself whatever the response's size.
|
|
5
|
+
*/
|
|
6
|
+
/** What a scan found. */
|
|
7
|
+
export interface ScanResult {
|
|
8
|
+
readonly model: string | undefined;
|
|
9
|
+
readonly usage: TokenUsage | undefined;
|
|
10
|
+
readonly error: ErrorInfo | undefined;
|
|
11
|
+
}
|
|
12
|
+
/** A body scanner: fed decoded text in arrival order, read once the body has ended. */
|
|
13
|
+
export interface BodyScanner {
|
|
14
|
+
readonly push: (text: string) => void;
|
|
15
|
+
readonly result: () => ScanResult;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Scans an Anthropic-format server-sent-events stream. Lines are split as they arrive, across chunk boundaries anywhere; an `event:` line names the event its `data:` lines belong to, and only metadata events' data is accumulated. A data line whose event is not named is held only until its leading `"type"` shows whether it is a metadata event, so content is never accumulated beyond those opening bytes.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createSseScanner(): BodyScanner;
|
|
21
|
+
/**
|
|
22
|
+
* Scans a JSON response body (a non-streaming Messages response, or an error body) for its top-level `model`, `usage` and `error`, capturing only those values' text. A small state machine tracks nesting and strings, so a `usage` key nested inside content is never mistaken for the top-level one, and content is walked past without being kept.
|
|
23
|
+
*/
|
|
24
|
+
export declare function createJsonScanner(): BodyScanner;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FarmFs } from "../launcher/ports";
|
|
2
|
+
import type { LayoutPaths } from "../paths";
|
|
3
|
+
import { type AccountMetadata, type ProviderQuota, type ProviderUsageState, type UsageRecord } from "./schema";
|
|
4
|
+
/** The filesystem effects the usage store's writer performs, all owner-only. */
|
|
5
|
+
export type UsageFs = Pick<FarmFs, "mkdirPrivate" | "writeFilePrivate" | "appendFilePrivate" | "readFileUtf8" | "readdir" | "removeRecursive">;
|
|
6
|
+
/**
|
|
7
|
+
* How long the usage log keeps records: seven days, the longest quota window any upstream agent-shim routes to enforces (Anthropic's subscription `seven_day` window, and the weekly windows z.ai's GLM Coding Plan and MiniMax's Token Plan document). Consumers ask the log how a credential's quota was spent within its current windows; a record older than the longest window can no longer count against any of them. Segments are pruned whole, by day, so the log always covers at least the full window.
|
|
8
|
+
*/
|
|
9
|
+
export declare const USAGE_RETENTION_MS: number;
|
|
10
|
+
/** Removes every log segment whose day ended before the retention window began. Safe to race: removing a segment another pruner already removed does nothing. */
|
|
11
|
+
export declare function pruneUsageLog(fs: Pick<UsageFs, "readdir" | "removeRecursive">, logDir: string, nowMs: number): void;
|
|
12
|
+
/** Everything the store's writer depends on. */
|
|
13
|
+
export interface UsageStoreDeps {
|
|
14
|
+
readonly fs: UsageFs;
|
|
15
|
+
readonly paths: Pick<LayoutPaths, "usageDir" | "usageLogDir" | "usageSnapshotsDir">;
|
|
16
|
+
/** This process's pid, which names its own log segments: one writer per segment is what makes concurrent writers safe without locking. */
|
|
17
|
+
readonly pid: number;
|
|
18
|
+
readonly now: () => number;
|
|
19
|
+
/** The identity's current account metadata, copied into its snapshot on every write so the snapshot is never staler than the last request. */
|
|
20
|
+
readonly readAccount: (identity: string) => AccountMetadata | undefined;
|
|
21
|
+
readonly log: (line: string) => void;
|
|
22
|
+
}
|
|
23
|
+
/** The store's writer. */
|
|
24
|
+
export interface UsageStore {
|
|
25
|
+
/** Validates and appends one record, then refreshes its identity's snapshot. Throws on a write failure; the caller (the usage middleware) catches. */
|
|
26
|
+
readonly record: (record: UsageRecord) => void;
|
|
27
|
+
/** Attaches a provider's pulled quota to the identity's existing state for that provider, keeping the newer observation when one is already there. Returns false when the identity has no state for the provider (a quota is only ever attached to usage that was seen). Throws on a write failure. */
|
|
28
|
+
readonly recordQuota: (identity: string, provider: string, quota: ProviderQuota) => boolean;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Folds one record into its provider's latest state. Each part keeps whichever observation is newest, so records folded out of order (two door generations overlapping, each writing its own) still leave the latest state, never an older one.
|
|
32
|
+
*/
|
|
33
|
+
export declare function foldProviderState(current: ProviderUsageState | undefined, record: UsageRecord): ProviderUsageState;
|
|
34
|
+
/**
|
|
35
|
+
* The usage store's writer, owned by the front-door process.
|
|
36
|
+
*
|
|
37
|
+
* The log is append-only JSON Lines under `usage/log/`, one segment per writing process per UTC day (`<YYYY-MM-DD>.<pid>.jsonl`). Every segment has exactly one writer, so concurrent writers (two door generations overlapping during a restart) can never interleave within a line, and readers (`agent-shim usage`, other tools) need no lock: the only partial line they can meet is a segment's unfinished last one, which they skip. Retention is enforced by pruning whole segments on the first write of each day.
|
|
38
|
+
*
|
|
39
|
+
* Each identity's snapshot (`usage/snapshots/<identity>.json`) is rewritten atomically (a temporary sibling renamed into place) after every record, so a reader always sees a whole snapshot. Two writers racing on one snapshot can each overwrite the other's newest observation for a provider, but never corrupt the file; the next record repairs it, and the log, not the snapshot, is the record of truth. A snapshot that cannot be read (a newer schema, a corrupted file) is replaced with a fresh one, and the replacement is logged.
|
|
40
|
+
*
|
|
41
|
+
* Everything is created owner-only: the directories 0700, the files 0600.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createUsageStore(deps: UsageStoreDeps): UsageStore;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** One entry observed inside a versions directory (e.g. ~/.local/share/claude/versions/2.1.220). */
|
|
2
|
+
export interface VersionsDirEntry {
|
|
3
|
+
/** The entry's own name — expected to be a plain dotted-numeric version like "2.1.220". */
|
|
4
|
+
name: string;
|
|
5
|
+
/** Whether the entry is a regular file (not a directory, symlink-to-directory, etc). */
|
|
6
|
+
isFile: boolean;
|
|
7
|
+
/** Whether the entry has at least one execute bit set. */
|
|
8
|
+
isExecutable: boolean;
|
|
9
|
+
/** The entry's size in bytes. */
|
|
10
|
+
sizeBytes: number;
|
|
11
|
+
}
|
|
12
|
+
/** Injected dependencies for discoverClaudeBinary, so no test ever touches a real filesystem or PATH. */
|
|
13
|
+
export interface DiscoverClaudeBinaryOptions {
|
|
14
|
+
/** Directory to scan for versioned claude binaries. Defaults to ~/.local/share/claude/versions. */
|
|
15
|
+
versionsDir?: string;
|
|
16
|
+
/** Lists the entries of a versions directory. Injected so tests never touch the real filesystem. */
|
|
17
|
+
listVersionsDir: (dir: string) => VersionsDirEntry[];
|
|
18
|
+
/** The PATH-like list of directories to search as a fallback, in order. */
|
|
19
|
+
pathDirs: string[];
|
|
20
|
+
/** Looks up an executable of the given name inside one PATH directory, returning its full path if found. */
|
|
21
|
+
findExecutableInDir: (dir: string, name: string) => string | undefined;
|
|
22
|
+
/** This tool's own install directory(ies), excluded from the PATH fallback search to avoid self-selection. */
|
|
23
|
+
ownInstallDirs: string[];
|
|
24
|
+
}
|
|
25
|
+
/** The result of a successful discovery. */
|
|
26
|
+
export interface DiscoveredClaudeBinary {
|
|
27
|
+
/** Full path to the discovered `claude` executable. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Which strategy found it. */
|
|
30
|
+
source: "versions-dir" | "path-fallback";
|
|
31
|
+
/** The version string, when discovered via the versions directory. */
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
/** True when `name` is a plain dotted-numeric version string like "2.1.220" (never full semver — no pre-release/build metadata is expected here). */
|
|
35
|
+
export declare function isNumericDottedVersion(name: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Compares two plain dotted-numeric version strings segment by segment, treating a missing trailing segment as 0 (so "2.1" sorts before "2.1.1"). Returns a negative number when `a` is the earlier version, a positive number when `a` is the later one, and 0 when equal. Throws if either string isn't a valid numeric-dotted version — callers must filter with isNumericDottedVersion first, since a naive string/lexicographic sort would incorrectly rank "2.9.0" ahead of "2.10.0".
|
|
38
|
+
*/
|
|
39
|
+
export declare function compareVersions(a: string, b: string): number;
|
|
40
|
+
/**
|
|
41
|
+
* Filters `entries` to genuinely-executable, non-empty regular files whose name is a valid dotted-numeric version (skipping things like .DS_Store or a stray empty file), then picks the highest version by a real numeric-segment comparison. Returns undefined when nothing qualifies.
|
|
42
|
+
*/
|
|
43
|
+
export declare function pickHighestVersion(entries: readonly VersionsDirEntry[]): string | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Discovers the real `claude` binary to launch: first by scanning the versions directory and picking the highest genuinely-executable version, then falling back to a PATH search for a `claude` executable (excluding this tool's own install directory, so the launcher never recursively selects itself). Throws a clear, actionable error when neither strategy finds anything — this is a deliberate improvement over the legacy bash tool, which crashed cryptically on a missing or empty versions directory instead of reporting the problem.
|
|
46
|
+
*/
|
|
47
|
+
export declare function discoverClaudeBinary(options: DiscoverClaudeBinaryOptions): DiscoveredClaudeBinary;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-shim",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.9.0",
|
|
4
4
|
"description": "A profile manager and launcher for Claude Code that lets one person run multiple logins from one machine while controlling what gets shared between them.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Joseph Mearman <joseph@mearman.co.uk>",
|