agent-shim 7.8.0 → 7.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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.8.0",
3
+ "version": "7.10.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>",
@@ -1,40 +0,0 @@
1
- import { UsageError } from "../cliError";
2
- /** One `<key>=<value>` pair, as parsed by `parsePair`. */
3
- export interface ParsedPair {
4
- readonly key: string;
5
- readonly value: string;
6
- }
7
- /**
8
- * Splits `input` on its *first* `=` into a key and a value. The value may itself contain further `=` characters (they stay part of the value); only the first `=` is treated as the separator.
9
- *
10
- * Throws when there is no `=` at all, or when the key half is empty (`=true`, or a leading `=`).
11
- */
12
- export declare function parsePair(input: string): ParsedPair;
13
- /** Parses `input` as a boolean in the shared vocabulary (`true`/`1`, `false`/`0`); throws on anything else. */
14
- export declare function parseBool(input: string): boolean;
15
- /** Raised when a boolean environment variable holds something outside the shared vocabulary. */
16
- export declare class InvalidEnvBoolError extends UsageError {
17
- readonly variable: string;
18
- readonly value: string;
19
- constructor(variable: string, value: string);
20
- }
21
- /**
22
- * Reads a boolean environment variable with the same vocabulary a CLI flag value uses. Unset and the empty string both mean "not given" (undefined), consistent with how every other agent-shim environment variable treats the empty string; anything outside the vocabulary throws `InvalidEnvBoolError` rather than silently reading as false.
23
- */
24
- export declare function parseEnvBool(variable: string, value: string | undefined): boolean | undefined;
25
- /**
26
- * Parses a comma-separated list of `<key>=<bool>` pairs into a plain object, e.g. `"history=true,knowledge=false"` becomes `{ history: true, knowledge: false }`. Used only for the list-valued environment variables; flags repeat instead.
27
- *
28
- * An empty string parses to `{}`. A key repeated within the same list is not an error: the later occurrence wins, matching how a plain object literal with a repeated key behaves.
29
- */
30
- export declare function parseBoolPairList(input: string): Record<string, boolean>;
31
- /**
32
- * Commander repeatable-option collector for one `--flag <key>=<bool>` occurrence: parses `value` and merges it over `previous`, so `--category history=true --category knowledge=false` accumulates into one object, the later occurrence winning on key collision.
33
- */
34
- export declare function collectBoolPair(value: string, previous?: Readonly<Record<string, boolean>>): Record<string, boolean>;
35
- /** Commander repeatable-option collector for one `--flag <KEY>=<VALUE>` occurrence with a free-text value, merged over `previous` the same way `collectBoolPair` merges. */
36
- export declare function collectStringPair(value: string, previous?: Readonly<Record<string, string>>): Record<string, string>;
37
- /** Commander repeatable-option collector for a plain repeated value (`--extends a --extends b`), preserving the order given. */
38
- export declare function collectRepeated(value: string, previous?: readonly string[]): string[];
39
- /** Commander option parser for a duration such as `30m`, `5h` or `7d` (a count followed by ms, s, m, h, d or w), returning milliseconds. A malformed value is a usage error naming the option. */
40
- export declare function parseDurationOption(value: string): number;