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,32 @@
1
+ import type { IncomingHttpHeaders } from "node:http";
2
+ /** What stands in for a credential while its request crosses headroom: an opaque word that authenticates nothing anywhere. */
3
+ export declare const SEQUESTERED_CREDENTIAL = "agent-shim-sequestered";
4
+ /** The credential headers one custody record holds, exactly as the client sent them. */
5
+ type HeldCredentials = Readonly<Record<string, string | readonly string[]>>;
6
+ /** One request's headers with its credentials swapped out, and the id that redeems them. */
7
+ interface Sequestered {
8
+ readonly hopId: string;
9
+ readonly headers: Record<string, string | string[] | undefined>;
10
+ }
11
+ /**
12
+ * Holds a provider session's real credentials in this process's memory while its request takes the headroom hop, so the credential never crosses headroom or the plain-HTTP leg back to the door.
13
+ *
14
+ * The hop calls `sequester`, sends headroom the placeholder headers plus the hop id, and calls `release` once its response to the client has finished or the client has gone. The direct listener calls `redeem` with the hop id and the provider its request path names: a live id recorded for that same provider yields the real headers, anything else yields nothing and the request is refused. A process that binds the direct port therefore receives only placeholders and an id that is worthless once the hop ends, and nothing outside this process can mint an id.
15
+ *
16
+ * An id stays redeemable for the whole hop rather than exactly once, because headroom legitimately sends one client request upstream more than once: it retries 429, 529 and 5xx answers, and its memory-tool and CCR continuations call the upstream again inside the same client turn, all with the headers it was handed.
17
+ */
18
+ export interface CredentialCustody {
19
+ /** Takes custody of `headers`' credential headers for one hop to `provider`, returning the headers to send headroom (placeholders where the credentials were) and the hop id that redeems them. */
20
+ readonly sequester: (headers: Readonly<IncomingHttpHeaders>, provider: string) => Sequestered;
21
+ /** Ends a hop: its id stops redeeming. Idempotent. */
22
+ readonly release: (hopId: string) => void;
23
+ /** The real credential headers for a live hop to `provider`, or undefined for an unknown, released or mismatched id. */
24
+ readonly redeem: (hopId: string, provider: string) => HeldCredentials | undefined;
25
+ }
26
+ /** Creates an empty custody store. `randomId` must be unguessable (a v4 UUID or better): the id is the only thing that redeems a credential. */
27
+ export declare function createCredentialCustody(randomId: () => string): CredentialCustody;
28
+ /**
29
+ * The headers a redeemed request is routed with: every credential header headroom forwarded (placeholders, or anything else headroom chose to send) is dropped, and the held originals take their place. Whatever the hop's request carried in those headers is never trusted; only what the client sent is.
30
+ */
31
+ export declare function restoreCredentials(headers: Readonly<IncomingHttpHeaders>, credentials: HeldCredentials): IncomingHttpHeaders;
32
+ export {};
@@ -0,0 +1,22 @@
1
+ import type { CredentialCustody } from "./custody";
2
+ import { type RoutedRequest, type RoutedResponse } from "./route";
3
+ /** Everything the headroom hop depends on, injected so the pipeline's ordering tests can point it at a scripted daemon. */
4
+ export interface HeadroomHopDeps {
5
+ /**
6
+ * The headroom daemon's loopback port, read per request rather than captured at start-up: the daemon can crash and restart on a different port while this door keeps listening, and the hop must follow it. Undefined while the daemon is down (the restart window).
7
+ */
8
+ readonly headroomPort: () => number | undefined;
9
+ /**
10
+ * The per-generation secret the direct listener requires on what the hop forwards back. Headroom passes non-x-headroom headers through untouched, so the secret survives the round trip and arrives where this process can check it, while no loopback process outside this door ever holds it.
11
+ */
12
+ readonly hopSecret: string;
13
+ /** Holds a provider session's real credentials while its request crosses headroom; the direct listener redeems them by the hop id. */
14
+ readonly custody: CredentialCustody;
15
+ readonly log: (line: string) => void;
16
+ }
17
+ /**
18
+ * Applies the headroom hop: forwards the request, still Anthropic-shaped, to the headroom daemon as a backend hop and streams its response back to the client. The hop ALWAYS sits before the route's own serving (a translator needs the request headroom has compressed, never the reverse), which is why this runs in the pipeline and not inside any route.
19
+ *
20
+ * `upstream` is what headroom is told to forward to once it has done its work (its per-request base URL header). When it is set it is always this door's direct listener (every provider route declares exactly that), and the provider credential is then taken into custody for the hop: headroom receives placeholders and a hop id, and the direct listener restores the real headers when headroom forwards the request back. Neither headroom nor anything that binds a port on that plain-HTTP round trip ever holds the credential. Undefined leaves headroom's default upstream (Claude Code's API) in charge, which is exactly what an OAuth session wants, and the OAuth bearer then passes through untouched: headroom forwards it straight to that API, never back here, and uses it for its subscription tracking. The session's project identity is re-set here, and only here: the identity step stripped it from everything that leaves the machine, and headroom is the one consumer that needs it.
21
+ */
22
+ export declare function applyHeadroomHop(request: RoutedRequest, response: RoutedResponse, upstream: string | undefined, deps: HeadroomHopDeps): Promise<void>;
@@ -0,0 +1,109 @@
1
+ import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
2
+ import { type HeadroomHopDeps } from "./headroomHop";
3
+ import { type FrontDoorRoute, type RoutedRequest, type RoutedResponse, type SessionIdentity } from "./route";
4
+ /** What one routed response looked like as it started back to the client: the typed event response middleware observes. */
5
+ export interface RoutedResponseEvent {
6
+ readonly session: SessionIdentity;
7
+ /** The route that served the response. */
8
+ readonly route: string;
9
+ /** The request's method. */
10
+ readonly method: string;
11
+ /** The request target's path, without its query string. */
12
+ readonly path: string;
13
+ /** When the pipeline received the request, in epoch milliseconds from the injected clock. */
14
+ readonly receivedAt: number;
15
+ readonly status: number;
16
+ readonly headers: Readonly<Record<string, string>>;
17
+ }
18
+ /** How a routed response's body finished: written to the end, or cut short (the client went away, or the route destroyed the response after an upstream failure). */
19
+ export type ResponseOutcome = "completed" | "aborted";
20
+ /**
21
+ * Follows one response's body as the client receives it. `chunk` sees each chunk after it has been handed to the client's socket, so observing never holds a byte back; `end` is called exactly once, when the body has finished either way. Like the observer that returned it, a body observer that throws is logged and dropped for the rest of that response, never allowed to break it.
22
+ */
23
+ export interface ResponseBodyObserver {
24
+ readonly chunk: (chunk: Uint8Array) => void;
25
+ readonly end: (outcome: ResponseOutcome) => void;
26
+ }
27
+ /**
28
+ * Response middleware: called once per routed response, in registration order, once the route's response head is known and before any of it is written to the client. It returns a body observer when it wants to follow the body, or undefined when the head was all it needed. Observing must be side-effect-only: an observer that throws is logged and skipped, never allowed to break routing, and an observer must not block the response.
29
+ *
30
+ * Usage tracking registers here (see `src/usage/middleware.ts`): the head carries the rate-limit headers, and the body carries the token usage.
31
+ */
32
+ export type ResponseObserver = (event: RoutedResponseEvent) => ResponseBodyObserver | undefined;
33
+ /** The outcome of resolving a request's target to a route. */
34
+ export type RouteResolution = {
35
+ readonly ok: true;
36
+ readonly route: FrontDoorRoute;
37
+ } | {
38
+ readonly ok: false;
39
+ readonly status: number;
40
+ readonly message: string;
41
+ };
42
+ /** Everything the ordered pipeline depends on, injected so it runs against fakes in tests. */
43
+ export interface PipelineDeps {
44
+ /** Resolves the route a request's target names, reading whatever configuration the route needs. */
45
+ readonly resolveRoute: (request: RoutedRequest) => Promise<RouteResolution>;
46
+ /** The response middleware chain, run in order at each response head. This is where usage capture registers. */
47
+ readonly responseObservers: readonly ResponseObserver[];
48
+ /**
49
+ * The headroom hop, applied BEFORE a route whose declaration admits it whenever the session's launch resolved headroom on: headroom needs the Anthropic-shaped request, so it always sits in front of a translator, never behind one. Omitted by a listener that serves routes directly (the direct listener headroom forwards an in-process route back to), or when no headroom is wired.
50
+ */
51
+ readonly headroom?: HeadroomHopDeps;
52
+ /** The clock response middleware times requests by, in epoch milliseconds. */
53
+ readonly now: () => number;
54
+ readonly log: (line: string) => void;
55
+ /**
56
+ * The listener's admission step, run after identification and before anything is routed: it decides whether the request may be routed at all and with which headers. A client-facing listener admits a request carrying the per-launch capability of a live registered session, unchanged; the direct listener admits only what this process's own headroom hop sent back (the generation's hop secret plus a live custody id for the provider the path names) and swaps the hop's placeholder credentials for the real ones. A refused request is answered 401 without ever reaching a route, so a loopback process that never launched through agent-shim cannot spend a session's credentials or quota.
57
+ */
58
+ readonly admit: (request: AdmissionRequest) => Admission;
59
+ }
60
+ /** What the admission step sees of one request. */
61
+ interface AdmissionRequest {
62
+ /** The request target exactly as received. */
63
+ readonly url: string;
64
+ /** Every header as received, internal ones included: the capability and hop headers live here. */
65
+ readonly headers: Readonly<IncomingHttpHeaders>;
66
+ /** The headers the identity step judged forwardable, internal ones stripped. */
67
+ readonly forwardable: Readonly<IncomingHttpHeaders>;
68
+ }
69
+ /** The admission step's verdict: route with these headers, or refuse with this message. */
70
+ type Admission = {
71
+ readonly ok: true;
72
+ readonly headers: IncomingHttpHeaders;
73
+ } | {
74
+ readonly ok: false;
75
+ readonly message: string;
76
+ };
77
+ /** One request as a transport hands it to the pipeline, before anything has been identified or routed. */
78
+ export interface PipelineRequest {
79
+ readonly method: string;
80
+ /** The request target exactly as received, path and query together. */
81
+ readonly url: string;
82
+ readonly headers: IncomingHttpHeaders;
83
+ /** The request's body stream. */
84
+ readonly body: IncomingMessage;
85
+ /** Aborted the moment the client goes away. */
86
+ readonly signal: AbortSignal;
87
+ readonly response: ServerResponse;
88
+ }
89
+ /** What the response middleware learns about the request a routed response answers. */
90
+ interface ObservedRequest {
91
+ readonly method: string;
92
+ /** The request target's path, without its query string. */
93
+ readonly path: string;
94
+ readonly receivedAt: number;
95
+ }
96
+ /**
97
+ * Builds the response half a route writes through. The head is what runs the response middleware: `start` invokes every observer, in order, with the session, the request and the route's name before a byte reaches the client, and keeps the body observers they return. Each body chunk reaches those observers only after it has been handed to the client's socket, and their `end` runs once when the response ends, is destroyed, or the client goes away first, so following the body never holds a byte back and never misses how it finished. Body writes apply backpressure by waiting for the socket to drain, and resolve without writing once the client is gone, so a route streaming to a dead client finishes promptly rather than filling a buffer.
98
+ */
99
+ export declare function createRoutedResponse(response: ServerResponse, context: {
100
+ readonly deps: PipelineDeps;
101
+ readonly session: SessionIdentity;
102
+ readonly route: string;
103
+ readonly request: ObservedRequest;
104
+ }): RoutedResponse;
105
+ /**
106
+ * The ordered pipeline every routed session's request runs through: identify the session (splitting the launcher-injected headers out of what may leave the machine), then route, with the response middleware running at each response head. A route that cannot be resolved is answered as an Anthropic-shaped error, and the observers see that error like any other response, since quota and rate-limit state lives in exactly those.
107
+ */
108
+ export declare function serveRouted(request: PipelineRequest, deps: PipelineDeps): Promise<void>;
109
+ export {};
@@ -0,0 +1,44 @@
1
+ import http from "node:http";
2
+ import type { IncomingMessage } from "node:http";
3
+ import https from "node:https";
4
+ import type { LeafCert } from "./connect";
5
+ import type { PipelineRequest } from "./pipeline";
6
+ /** A front-door listener: plain HTTP for the direct listener only headroom reaches, HTTPS for the provider listener every provider session's child connects to. */
7
+ export type FrontDoorServer = http.Server | https.Server;
8
+ /**
9
+ * A front-door listener: the transport a routed session's requests arrive on. It turns each Node request into a `PipelineRequest` (the raw request plus an abort signal that fires the moment the client goes away before the response finished) and hands it to the pipeline; the pipeline owns identification, admission, middleware and routing.
10
+ *
11
+ * With `tls`, the listener serves HTTPS with that leaf, which is how the provider listener authenticates itself to the child: the leaf is signed by agent-shim's CA, whose key only the owning user can read, and the only CA the child trusts for a 127.0.0.1 certificate is that one (no public CA issues certificates for a loopback address), so a process that merely binds the port cannot complete a handshake the child accepts and never receives the request (credentials and capability included). Without `tls` it serves plain HTTP, which only the direct listener does: nothing that reaches it carries a real credential (see the credential custody).
12
+ *
13
+ * The disconnect is read from the response's `close` event, not the request's: the request emits `close` as soon as its body has been read, long before the response ends. `GET /healthz` is answered here, before the pipeline, because a readiness probe is not a routed session and carries no session headers or capability.
14
+ */
15
+ export declare function createFrontDoorServer(pipeline: (request: PipelineRequest) => Promise<void>, log: (line: string) => void, tls?: LeafCert): FrontDoorServer;
16
+ /**
17
+ * Probes a listener's health endpoint once: the check the supervisor's start and the tests use to know the door is actually serving, not merely bound. With `ca`, the probe speaks HTTPS and accepts only a certificate chaining to that CA for 127.0.0.1, so a healthy answer also proves who is answering; without it, plain HTTP. No capability is ever sent: the health endpoint needs none.
18
+ */
19
+ export declare function frontDoorHealthy(port: number, ca?: string): Promise<boolean>;
20
+ /** How a listener is bound. */
21
+ interface ListenOptions {
22
+ /** The sticky port to try first; any free port is used when it is taken or absent. */
23
+ readonly preferredPort?: number;
24
+ /** Receives the actual port the moment the bind succeeds, before the health probe. */
25
+ readonly onBound?: (port: number) => void;
26
+ /** The CA a TLS listener's leaf chains to, so its own start-up probe verifies the handshake a child will make; omitted for a plain-HTTP listener. */
27
+ readonly ca?: string;
28
+ /**
29
+ * Called for any error the listener emits once it is bound and serving. Attached only after the bind has settled: a server emits the sticky port's EADDRINUSE as an ordinary `error` event, and a handler attached before the bind would treat the fallback the bind is about to make as fatal.
30
+ */
31
+ readonly onError?: (error: Error) => void;
32
+ }
33
+ /**
34
+ * Binds the listener on `preferredPort` when it is free and any free port otherwise (bind, do not probe: check-then-bind races), probing its own health endpoint before resolving.
35
+ */
36
+ export declare function listenFrontDoor(server: FrontDoorServer, options?: Readonly<ListenOptions>): Promise<{
37
+ readonly port: number;
38
+ readonly close: () => Promise<void>;
39
+ }>;
40
+ /**
41
+ * Iterates a forwarded response's body as typed chunks. Node types the async iteration of an `IncomingMessage` as `any`, so every consumer narrows here once rather than each carrying its own assertion.
42
+ */
43
+ export declare function upstreamChunks(stream: IncomingMessage): AsyncGenerator<Uint8Array>;
44
+ export {};
@@ -9,7 +9,9 @@
9
9
  *
10
10
  * Creating and changing identities, configuration profiles, providers, pools and directory rules: the `*Store` modules, which take the state root's `LayoutPaths`, return typed values and throw `CliError` subclasses.
11
11
  *
12
- * Everything the CLI alone needs (commander wiring, prompts, `doctor`, `check`) is deliberately not exported here.
12
+ * - `check` and `doctor` as data: `collectCheckReport` and `collectDoctorReport` read this machine and return the report, and the pure `runCheck` and `runDoctor` take the facts as parameters.
13
+ *
14
+ * Everything the CLI alone needs (commander wiring and prompts) is deliberately not exported here.
13
15
  */
14
16
  export { resolveDecisions, topLevelNames, type ResolveDecisionsInput, type ResolvedState } from "./resolve/pipeline";
15
17
  export type { CompiledRule, Decision, Diagnostic, EliminatedRule, EntryFact, EntryFacts, FlattenedCascade, Layer, LayerId } from "./resolve/types";
@@ -31,3 +33,5 @@ export { createProfile, InvalidCategoryNameError, listProfiles, ProfileAlreadyEx
31
33
  export { addProvider, describeProviderEndpoint, InvalidProviderNameError, LegacyProviderFileError, listProviders, ProviderAlreadyExistsError, ProviderKindMismatchError, ProviderNotFoundError, providerExists, readProvider, removeProvider, updateProvider, type AddProviderInput, type ProviderListEntry, type UpdateProviderInput, } from "./providersStore";
32
34
  export { addPool, PoolNotFoundError, readPools, removePool, requirePool, setPool } from "./poolStore";
33
35
  export { addDirectoryRule, DirectoryRuleAlreadyExistsError, DirectoryRuleMissingTargetError, DirectoryRuleNotFoundError, listDirectoryRules, readDirectoryRules, removeDirectoryRule, updateDirectoryRule, writeDirectoryRules, type AddDirectoryRuleOptions, type UpdateDirectoryRuleOptions, } from "./directoryRulesStore";
36
+ export { checkReportHasWarnings, checkReportToJson, collectCheckReport, formatCheckReport, runCheck, type CheckReport, type CollectCheckReportParams, type RunCheckParams, } from "./checkReport";
37
+ export { collectDoctorReport, formatDoctorReport, runDoctor, type CollectDoctorReportParams, type DoctorReport, type RunDoctorParams, } from "./doctorReport";
@@ -0,0 +1,42 @@
1
+ import { type ConfigFileReader } from "../config/load";
2
+ import { type GlobalConfig } from "../config/schema";
3
+ import { type CascadeInput, type ReadablePredicate } from "../resolve/walk";
4
+ import type { LayoutPaths } from "../paths";
5
+ /** The committed, team-shared portable config file name. A level with none falls back to the former `.claude-use.json`, and likewise for the local file, see `src/legacy.ts`. */
6
+ export declare const PORTABLE_CONFIG_FILENAME = ".agent-shim.json";
7
+ /** Its gitignored, per-clone personal sibling. */
8
+ export declare const PORTABLE_LOCAL_CONFIG_FILENAME = ".agent-shim.local.json";
9
+ /** Inputs to `loadCascadeInput`. */
10
+ export interface LoadCascadeInputParams {
11
+ readonly paths: LayoutPaths;
12
+ readonly home: string;
13
+ /** The directory the cascade is being resolved for — already a real path, since resolving symlinks is the caller's job. */
14
+ readonly cwd: string;
15
+ readonly read: ConfigFileReader;
16
+ readonly isReadable?: ReadablePredicate;
17
+ /** The configuration profile the launcher's own precedence rules chose. */
18
+ readonly baseConfigProfile?: string;
19
+ readonly cliOverride?: CascadeInput["cliOverride"];
20
+ }
21
+ /** An assembled cascade input plus the global config it was built from, which the caller also needs for its own defaults. */
22
+ export interface LoadedCascade {
23
+ readonly input: CascadeInput;
24
+ readonly globalConfig?: GlobalConfig;
25
+ }
26
+ /**
27
+ * Loads every configuration file one launch's cascade is built from, and assembles them into the `CascadeInput` the pure resolver consumes.
28
+ *
29
+ * This is the only place file reading and cascade composition meet. Everything it produces is already-loaded data: `src/resolve/` never opens a file, and this module never decides what any of it means.
30
+ *
31
+ * Files are read with `loadConfigFile`, which wraps cosmiconfig's `load(filepath)` — never its `search()`, which stops at the first config found while walking upward. This design needs the exact opposite: every ancestor collected, shallowest-first, each one composing on top of the last.
32
+ */
33
+ export declare function loadCascadeInput(params: LoadCascadeInputParams): LoadedCascade;
34
+ /**
35
+ * Reads the deepest directory-level `identity` pin and `configProfile` selection that apply to a directory, without resolving the cascade.
36
+ *
37
+ * The launcher needs both *before* it can resync a farm — which identity a directory pins decides which farm there is to resync, and which profile it selects is an input to resolving that farm's contents — so they cannot come out of the resolved cascade itself.
38
+ */
39
+ export declare function readDirectorySelections(loaded: LoadedCascade): {
40
+ identity?: string;
41
+ configProfile?: string;
42
+ };
@@ -0,0 +1,44 @@
1
+ import type { Pool } from "../config/schema";
2
+ import type { LayoutPaths } from "../paths";
3
+ import { type PoolPickFs } from "../usage/poolPick";
4
+ import type { FarmFs, FsPort, LogPort } from "./ports";
5
+ /** Everything one pool pick needs, injected so it runs against the same fakes as the rest of the launcher. */
6
+ export interface ResolvePoolParams {
7
+ readonly poolName: string;
8
+ /** Which precedence rule selected the pool, for the refusal messages. */
9
+ readonly selectedVia: string;
10
+ readonly pools: Readonly<Record<string, Pool>> | undefined;
11
+ readonly paths: LayoutPaths;
12
+ readonly fs: FsPort;
13
+ readonly usageFs: PoolPickFs & Pick<FarmFs, "writeFilePrivate" | "mkdirPrivate">;
14
+ /** The launch directory: the key of the last-pick record. */
15
+ readonly cwd: string;
16
+ readonly now: () => number;
17
+ readonly sleep: (ms: number) => void;
18
+ /** The arguments claude itself will receive. */
19
+ readonly passthrough: readonly string[];
20
+ /** Sleep until the earliest member returns, instead of refusing, when every member is refused. */
21
+ readonly wait: boolean;
22
+ readonly log: LogPort;
23
+ }
24
+ /** The pick, or why none could be made. */
25
+ export type PoolResolution = {
26
+ readonly ok: true;
27
+ readonly identity: string;
28
+ /** The pool and the chosen member's leading reasons, for the launch's decision line. */
29
+ readonly explanation: string;
30
+ } | {
31
+ readonly ok: false;
32
+ readonly message: string;
33
+ };
34
+ /** A pool's members split by whether each is an identity that exists: a pool may outlive an identity it names, since removing an identity does not rewrite the pools that list it. */
35
+ export declare function splitMembers(pool: Pool, paths: Pick<LayoutPaths, "identitiesDir">, fs: FsPort): {
36
+ readonly present: readonly string[];
37
+ readonly missing: readonly string[];
38
+ };
39
+ /**
40
+ * Picks the identity a launch of pool `poolName` runs as.
41
+ *
42
+ * Members without an `identity.json` are skipped with a warning, since a pool may outlive an identity it names. When every remaining member is refused right now the launch is refused, naming the earliest return, unless `wait` is set, which sleeps until then and ranks again. The chosen member is recorded as this directory's last pick so the next launch here keeps its warm prompt cache.
43
+ */
44
+ export declare function resolvePoolLaunch(params: ResolvePoolParams): PoolResolution;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Expands a leading `~` against `home`.
3
+ *
4
+ * Only a leading `~` alone or followed by a separator is expanded — `~other` is a literal name (a directory really called that), not another user's home directory, which this tool has no business resolving.
5
+ */
6
+ export declare function expandTilde(value: string, home: string): string;
7
+ /**
8
+ * Normalises a directory rule's `path` into an absolute filesystem path for comparison against a real directory.
9
+ *
10
+ * Rules are written with `~`-rooted paths far more often than absolute ones, and a rule that fails to match because of a trailing slash or a `..` segment fails silently — the launch just quietly ignores the rule. Normalising both sides identically is what stops that.
11
+ */
12
+ export declare function normaliseRulePath(rulePath: string, home: string): string;
13
+ /** True when `ancestor` is `descendant` itself or one of its parent directories. Both are expected to be already normalised. */
14
+ export declare function isAncestorOrSelf(ancestor: string, descendant: string): boolean;
@@ -0,0 +1,44 @@
1
+ import type { Pool } from "./config/schema";
2
+ import type { FarmFs, FsPort } from "./launcher/ports";
3
+ import type { LayoutPaths } from "./paths";
4
+ import type { Candidate } from "./usage/pick";
5
+ /** What `pool pick` reports: the ranking a launch from `directory` would act on right now. */
6
+ export interface PoolPickReport {
7
+ readonly pool: string;
8
+ readonly directory: string;
9
+ /** The member a launch would run as, absent when every member is refused. */
10
+ readonly pick?: string;
11
+ readonly candidates: readonly PoolPickCandidateView[];
12
+ /** Members that name an identity that does not exist; a launch skips them. */
13
+ readonly missing: readonly string[];
14
+ /** When nothing can be picked, the soonest any member returns. */
15
+ readonly earliestReturn?: {
16
+ readonly identity: string;
17
+ readonly at: string;
18
+ };
19
+ /** Why the last-pick record could not be read, when that is the case. */
20
+ readonly stickyProblem?: string;
21
+ }
22
+ /** One ranked member as `pool pick --json` prints it. */
23
+ interface PoolPickCandidateView {
24
+ readonly identity: string;
25
+ readonly class: Candidate["class"];
26
+ readonly score?: number;
27
+ readonly feasible: boolean;
28
+ readonly blockedUntil?: string;
29
+ readonly plan: Candidate["plan"];
30
+ readonly reasons: readonly string[];
31
+ }
32
+ /**
33
+ * Ranks a pool exactly as a launch from `directory` would right now, without recording a pick. Shares `rankPoolFromStore` with the launcher, so what this prints is what a launch does.
34
+ */
35
+ export declare function collectPoolPick(params: Readonly<{
36
+ paths: LayoutPaths;
37
+ fs: FsPort;
38
+ usageFs: FarmFs;
39
+ poolName: string;
40
+ pool: Pool;
41
+ directory: string;
42
+ nowMs: number;
43
+ }>): PoolPickReport;
44
+ export {};
@@ -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;