@tokenfactory/acc-runner 0.40.11 → 0.40.12

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,54 @@
1
+ import type { EngineAccountIdentity, EngineAuthMode, EnginePlanHint } from "./types.js";
2
+ /**
3
+ * Derive the stable synthetic account id.
4
+ * - With an email: `acct:<lowercased-trimmed-email>` — the SAME login on two
5
+ * hosts/channels collapses to one id, which is exactly the convergence the
6
+ * broadcaster wants.
7
+ * - Without an email: `host:<hostname>/<engineId>/<channel>` — stable for a
8
+ * given runner install + channel, distinct across hosts, and never null.
9
+ */
10
+ export declare function syntheticAccountId(args: {
11
+ engineId: string;
12
+ channel: EngineAuthMode;
13
+ email?: string | null;
14
+ hostname?: string;
15
+ }): string;
16
+ /**
17
+ * Build the HONEST "unknown-account" identity an adapter reports when it cannot
18
+ * introspect a real account (codex/gemini today, or claude-code in api-key mode
19
+ * where a bare key carries no email). Never fabricates an email or a plan.
20
+ */
21
+ export declare function unknownAccountIdentity(args: {
22
+ engineId: string;
23
+ channel: EngineAuthMode;
24
+ planHint?: EnginePlanHint;
25
+ detail: string;
26
+ hostname?: string;
27
+ }): EngineAccountIdentity;
28
+ /**
29
+ * Build a RESOLVED account identity from an introspected email (+ optional
30
+ * plan). The synthetic id is email-derived so it converges across hosts.
31
+ */
32
+ export declare function resolvedAccountIdentity(args: {
33
+ engineId: string;
34
+ channel: EngineAuthMode;
35
+ email: string;
36
+ planHint?: EnginePlanHint;
37
+ detail: string;
38
+ }): EngineAccountIdentity;
39
+ /**
40
+ * The identity used for change detection / broadcast keying. Two probes name the
41
+ * same account iff their `synthetic_account` matches (email-derived when known,
42
+ * host-derived otherwise) — so a login swap on one host, an email that appears
43
+ * where there was none, or a channel flip are all detected, while a plain
44
+ * cadence refresh that resolves the same account is a no-op.
45
+ */
46
+ export declare function accountKey(identity: EngineAccountIdentity): string;
47
+ /**
48
+ * True when `next` names a different account than `prev` (or there was no prior
49
+ * account). The broadcaster audits on `true` and last-writer-wins-skips on
50
+ * `false`. A plan_hint-only change (same account, upgraded plan) also counts as
51
+ * a change worth re-broadcasting so downstream cap logic sees the new plan.
52
+ */
53
+ export declare function accountChanged(prev: EngineAccountIdentity | null, next: EngineAccountIdentity): boolean;
54
+ //# sourceMappingURL=account-identity.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-identity.d.ts","sourceRoot":"","sources":["../../src/engines/account-identity.ts"],"names":[],"mappings":"AAoBA,OAAO,KAAK,EACV,qBAAqB,EACrB,cAAc,EACd,cAAc,EACf,MAAM,YAAY,CAAC;AAEpB;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE;IACvC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,MAAM,CAKT;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,qBAAqB,CAYxB;AAED;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,cAAc,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG,qBAAqB,CAaxB;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,QAAQ,EAAE,qBAAqB,GAAG,MAAM,CAElE;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,qBAAqB,GAAG,IAAI,EAClC,IAAI,EAAE,qBAAqB,GAC1B,OAAO,CAGT"}
@@ -0,0 +1,93 @@
1
+ /**
2
+ * AS-1 (ACCOUNT-PROBE): shared, side-effect-free helpers for the engine
3
+ * `accountIdentity()` surface (types.ts). Two concerns live here so every
4
+ * adapter (claude-code / codex / gemini) spells them identically:
5
+ *
6
+ * 1. Synthetic-account derivation — a STABLE id an engine reports so the
7
+ * identity broadcaster always has a concrete participant to converge on,
8
+ * even when the real account email is unintrospectable. Email-derived when
9
+ * an email resolves (same login → same id across hosts); host+engine+channel
10
+ * derived otherwise (an unknown-account runner is still stable + distinct
11
+ * rather than a flapping null).
12
+ * 2. Change detection / audit — `accountChanged()` compares the last-broadcast
13
+ * identity against a freshly-probed one so the last-writer-wins broadcaster
14
+ * can decide whether an emit is a no-op refresh or a real login swap worth
15
+ * auditing (the ajay→gaurav evidence that motivated the task).
16
+ *
17
+ * PURE by construction (the one impurity — hostname — is passed in), so the
18
+ * cadence + parsing logic is unit-testable without touching `os` or a clock.
19
+ */
20
+ import os from "node:os";
21
+ /**
22
+ * Derive the stable synthetic account id.
23
+ * - With an email: `acct:<lowercased-trimmed-email>` — the SAME login on two
24
+ * hosts/channels collapses to one id, which is exactly the convergence the
25
+ * broadcaster wants.
26
+ * - Without an email: `host:<hostname>/<engineId>/<channel>` — stable for a
27
+ * given runner install + channel, distinct across hosts, and never null.
28
+ */
29
+ export function syntheticAccountId(args) {
30
+ const email = (args.email ?? "").trim().toLowerCase();
31
+ if (email)
32
+ return `acct:${email}`;
33
+ const host = (args.hostname ?? os.hostname() ?? "").trim() || "unknown-host";
34
+ return `host:${host}/${args.engineId}/${args.channel}`;
35
+ }
36
+ /**
37
+ * Build the HONEST "unknown-account" identity an adapter reports when it cannot
38
+ * introspect a real account (codex/gemini today, or claude-code in api-key mode
39
+ * where a bare key carries no email). Never fabricates an email or a plan.
40
+ */
41
+ export function unknownAccountIdentity(args) {
42
+ return {
43
+ account_email: null,
44
+ plan_hint: args.planHint ?? "unknown",
45
+ channel: args.channel,
46
+ synthetic_account: syntheticAccountId({
47
+ engineId: args.engineId,
48
+ channel: args.channel,
49
+ hostname: args.hostname,
50
+ }),
51
+ detail: args.detail,
52
+ };
53
+ }
54
+ /**
55
+ * Build a RESOLVED account identity from an introspected email (+ optional
56
+ * plan). The synthetic id is email-derived so it converges across hosts.
57
+ */
58
+ export function resolvedAccountIdentity(args) {
59
+ const email = args.email.trim();
60
+ return {
61
+ account_email: email,
62
+ plan_hint: args.planHint ?? "unknown",
63
+ channel: args.channel,
64
+ synthetic_account: syntheticAccountId({
65
+ engineId: args.engineId,
66
+ channel: args.channel,
67
+ email,
68
+ }),
69
+ detail: args.detail,
70
+ };
71
+ }
72
+ /**
73
+ * The identity used for change detection / broadcast keying. Two probes name the
74
+ * same account iff their `synthetic_account` matches (email-derived when known,
75
+ * host-derived otherwise) — so a login swap on one host, an email that appears
76
+ * where there was none, or a channel flip are all detected, while a plain
77
+ * cadence refresh that resolves the same account is a no-op.
78
+ */
79
+ export function accountKey(identity) {
80
+ return identity.synthetic_account;
81
+ }
82
+ /**
83
+ * True when `next` names a different account than `prev` (or there was no prior
84
+ * account). The broadcaster audits on `true` and last-writer-wins-skips on
85
+ * `false`. A plan_hint-only change (same account, upgraded plan) also counts as
86
+ * a change worth re-broadcasting so downstream cap logic sees the new plan.
87
+ */
88
+ export function accountChanged(prev, next) {
89
+ if (!prev)
90
+ return true;
91
+ return accountKey(prev) !== accountKey(next) || prev.plan_hint !== next.plan_hint;
92
+ }
93
+ //# sourceMappingURL=account-identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-identity.js","sourceRoot":"","sources":["../../src/engines/account-identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,OAAO,EAAE,MAAM,SAAS,CAAC;AAOzB;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAKlC;IACC,MAAM,KAAK,GAAG,CAAC,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC;IACtD,IAAI,KAAK;QAAE,OAAO,QAAQ,KAAK,EAAE,CAAC;IAClC,MAAM,IAAI,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,IAAI,cAAc,CAAC;IAC7E,OAAO,QAAQ,IAAI,IAAI,IAAI,CAAC,QAAQ,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;AACzD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAMtC;IACC,OAAO;QACL,aAAa,EAAE,IAAI;QACnB,SAAS,EAAE,IAAI,CAAC,QAAQ,IAAI,SAAS;QACrC,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,iBAAiB,EAAE,kBAAkB,CAAC;YACpC,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,QAAQ,EAAE,IAAI,CAAC,QAAQ;SACxB,CAAC;QACF,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,uBAAuB,CAAC,IAMvC;IACC,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;IAChC,OAAO;QACL,aAAa,EAAE,KAAK;QACpB,SAAS,EAAE,IAAI,CAAC,QAAQ,IAAI,SAAS;QACrC,OAAO,EAAE,IAAI,CAAC,OAAO;QACrB,iBAAiB,EAAE,kBAAkB,CAAC;YACpC,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,OAAO,EAAE,IAAI,CAAC,OAAO;YACrB,KAAK;SACN,CAAC;QACF,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC;AACJ,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,UAAU,CAAC,QAA+B;IACxD,OAAO,QAAQ,CAAC,iBAAiB,CAAC;AACpC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAC5B,IAAkC,EAClC,IAA2B;IAE3B,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IACvB,OAAO,UAAU,CAAC,IAAI,CAAC,KAAK,UAAU,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,CAAC;AACpF,CAAC"}
@@ -0,0 +1,124 @@
1
+ /**
2
+ * AS-1 (ACCOUNT-PROBE): the account-probe CADENCE scheduler.
3
+ *
4
+ * Extends the engine credential probe with a WHO-am-I probe fired on four
5
+ * triggers so a mid-flight login swap can never go undetected (operator
6
+ * evidence: swapping the keychain login ajay→gaurav went undetected while the
7
+ * runner sat paused on the old account's reset timer):
8
+ *
9
+ * 1. startup — one probe the moment the scheduler starts.
10
+ * 2. pause-tick — every ~60s WHILE the runner is capacity-paused (the exact
11
+ * window the ajay→gaurav swap slipped through: a paused
12
+ * runner otherwise only re-probed on the 5-min heartbeat).
13
+ * 3. heartbeat — every ~5min while healthy (unpaused).
14
+ * 4. auth-failure — immediately after any 401/403-class engine failure
15
+ * (debounced so a burst of 401s is a single probe).
16
+ *
17
+ * Each probe result is handed to `emit` with a MONOTONIC sequence + wall-clock
18
+ * stamp so the identity broadcaster resolves concurrent writers last-writer-wins,
19
+ * and a `changed` flag so a real login swap is audited while a plain cadence
20
+ * refresh of the same account is a cheap no-op.
21
+ *
22
+ * FULLY injectable (probe / emit / clock / timer) so the four-trigger cadence is
23
+ * exercised with a fake clock — no real binary, no wall-clock sleep, no network.
24
+ * Wiring this scheduler onto the companion's identity broadcaster is a follow-up
25
+ * (companion.ts is outside this track's file scope); this module is the engine
26
+ * primitive that follow-up drives.
27
+ */
28
+ import type { EngineAccountIdentity } from "./types.js";
29
+ /** Which cadence trigger fired a given probe. */
30
+ export type AccountProbeTrigger = "startup" | "pause-tick" | "heartbeat" | "auth-failure";
31
+ /** One account-probe broadcast handed to the identity broadcaster. */
32
+ export interface AccountProbeBroadcast {
33
+ /** The probed account identity. */
34
+ identity: EngineAccountIdentity;
35
+ /** Which cadence trigger produced this probe. */
36
+ trigger: AccountProbeTrigger;
37
+ /**
38
+ * True when this probe named a DIFFERENT account (or plan) than the last
39
+ * broadcast — the audit signal a login swap fires on. False on a plain
40
+ * same-account cadence refresh.
41
+ */
42
+ changed: boolean;
43
+ /** Monotonic 1-based sequence for last-writer-wins resolution on the topic. */
44
+ seq: number;
45
+ /** Wall-clock ms of the probe (from the injected clock). */
46
+ at: number;
47
+ }
48
+ /** Timer seam so the cadence is drivable by a fake clock in tests. */
49
+ export interface ProbeTimer {
50
+ /** Schedule `fn` after `ms`; returns an opaque handle. */
51
+ setTimer(fn: () => void, ms: number): unknown;
52
+ /** Cancel a scheduled handle. */
53
+ clearTimer(handle: unknown): void;
54
+ }
55
+ export interface AccountProbeSchedulerDeps {
56
+ /** Probe the engine account. Should itself degrade to an honest `"unknown"` identity rather than throw. */
57
+ probe: () => Promise<EngineAccountIdentity>;
58
+ /** Emit a probed identity on the identity broadcaster (last-writer-wins keyed on `seq`). */
59
+ emit: (broadcast: AccountProbeBroadcast) => void | Promise<void>;
60
+ /** Healthy heartbeat cadence (ms). Default 5min. */
61
+ heartbeatMs?: number;
62
+ /** While-paused probe cadence (ms). Default 60s. */
63
+ pauseTickMs?: number;
64
+ /** Min gap between auth-failure probes (ms) so a 401 burst is one probe. Default 1s. */
65
+ authFailureDebounceMs?: number;
66
+ /** Clock (test seam). Default `Date.now`. */
67
+ now?: () => number;
68
+ /** Timer seam (test seam). Default `setTimeout`/`clearTimeout`. */
69
+ timer?: ProbeTimer;
70
+ /** Best-effort audit/log sink for detected swaps + probe errors. */
71
+ onLog?: (line: string) => void;
72
+ }
73
+ /**
74
+ * Drives the four-trigger account-probe cadence. Probes are SERIALIZED onto one
75
+ * chain so sequence numbers are strictly monotonic and two triggers never race
76
+ * to emit an out-of-order identity. Call `start()` to fire the startup probe and
77
+ * arm the recurring timer, `setPaused()` on capacity transitions, and
78
+ * `onAuthFailure()` from the engine failure path.
79
+ */
80
+ export declare class AccountProbeScheduler {
81
+ private readonly deps;
82
+ private readonly heartbeatMs;
83
+ private readonly pauseTickMs;
84
+ private readonly authFailureDebounceMs;
85
+ private readonly now;
86
+ private readonly timer;
87
+ private started;
88
+ private stopped;
89
+ private paused;
90
+ private recurringHandle;
91
+ private seq;
92
+ private last;
93
+ private lastAuthFailureAt;
94
+ /** Serial probe chain: every probe awaits the prior one so `seq` stays ordered. */
95
+ private tail;
96
+ constructor(deps: AccountProbeSchedulerDeps);
97
+ /** The last identity broadcast (null before the first probe). */
98
+ lastIdentity(): EngineAccountIdentity | null;
99
+ /** Fire the startup probe and arm the recurring cadence timer. Idempotent. */
100
+ start(): void;
101
+ /**
102
+ * Note a capacity pause/resume transition. Re-arms the recurring timer to the
103
+ * matching cadence (~60s paused, ~5min healthy). A no-op when the state is
104
+ * unchanged, so repeated pause ticks don't reset the timer out from under
105
+ * themselves.
106
+ */
107
+ setPaused(paused: boolean): void;
108
+ /**
109
+ * Probe immediately after a 401/403-class engine failure — the single most
110
+ * likely moment the underlying login just changed. Debounced so a burst of
111
+ * auth failures collapses to one probe.
112
+ */
113
+ onAuthFailure(): void;
114
+ /** Cancel the recurring timer; in-flight probes still settle. */
115
+ stop(): void;
116
+ /** Resolve once every queued probe has settled (test seam). */
117
+ settle(): Promise<void>;
118
+ private armRecurring;
119
+ private onRecurringFire;
120
+ /** Chain a probe onto the serial tail so sequence order is guaranteed. */
121
+ private enqueue;
122
+ private runProbe;
123
+ }
124
+ //# sourceMappingURL=account-probe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-probe.d.ts","sourceRoot":"","sources":["../../src/engines/account-probe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAGxD,iDAAiD;AACjD,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,CAAC;AAE1F,sEAAsE;AACtE,MAAM,WAAW,qBAAqB;IACpC,mCAAmC;IACnC,QAAQ,EAAE,qBAAqB,CAAC;IAChC,iDAAiD;IACjD,OAAO,EAAE,mBAAmB,CAAC;IAC7B;;;;OAIG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,4DAA4D;IAC5D,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,sEAAsE;AACtE,MAAM,WAAW,UAAU;IACzB,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC;IAC9C,iCAAiC;IACjC,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;CACnC;AAOD,MAAM,WAAW,yBAAyB;IACxC,2GAA2G;IAC3G,KAAK,EAAE,MAAM,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC5C,4FAA4F;IAC5F,IAAI,EAAE,CAAC,SAAS,EAAE,qBAAqB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wFAAwF;IACxF,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,6CAA6C;IAC7C,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,mEAAmE;IACnE,KAAK,CAAC,EAAE,UAAU,CAAC;IACnB,oEAAoE;IACpE,KAAK,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED;;;;;;GAMG;AACH,qBAAa,qBAAqB;IAiBpB,OAAO,CAAC,QAAQ,CAAC,IAAI;IAhBjC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAS;IAC/C,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IAEnC,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,MAAM,CAAS;IACvB,OAAO,CAAC,eAAe,CAAiB;IACxC,OAAO,CAAC,GAAG,CAAK;IAChB,OAAO,CAAC,IAAI,CAAsC;IAClD,OAAO,CAAC,iBAAiB,CAA4B;IACrD,mFAAmF;IACnF,OAAO,CAAC,IAAI,CAAoC;gBAEnB,IAAI,EAAE,yBAAyB;IAQ5D,iEAAiE;IACjE,YAAY,IAAI,qBAAqB,GAAG,IAAI;IAI5C,8EAA8E;IAC9E,KAAK,IAAI,IAAI;IAOb;;;;;OAKG;IACH,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI;IAMhC;;;;OAIG;IACH,aAAa,IAAI,IAAI;IAQrB,iEAAiE;IACjE,IAAI,IAAI,IAAI;IAQZ,+DAA+D;IACzD,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAI7B,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,eAAe;IAQvB,0EAA0E;IAC1E,OAAO,CAAC,OAAO;YAID,QAAQ;CA2BvB"}
@@ -0,0 +1,134 @@
1
+ import { accountChanged } from "./account-identity.js";
2
+ const DEFAULT_TIMER = {
3
+ setTimer: (fn, ms) => setTimeout(fn, ms),
4
+ clearTimer: (h) => clearTimeout(h),
5
+ };
6
+ /**
7
+ * Drives the four-trigger account-probe cadence. Probes are SERIALIZED onto one
8
+ * chain so sequence numbers are strictly monotonic and two triggers never race
9
+ * to emit an out-of-order identity. Call `start()` to fire the startup probe and
10
+ * arm the recurring timer, `setPaused()` on capacity transitions, and
11
+ * `onAuthFailure()` from the engine failure path.
12
+ */
13
+ export class AccountProbeScheduler {
14
+ deps;
15
+ heartbeatMs;
16
+ pauseTickMs;
17
+ authFailureDebounceMs;
18
+ now;
19
+ timer;
20
+ started = false;
21
+ stopped = false;
22
+ paused = false;
23
+ recurringHandle = null;
24
+ seq = 0;
25
+ last = null;
26
+ lastAuthFailureAt = Number.NEGATIVE_INFINITY;
27
+ /** Serial probe chain: every probe awaits the prior one so `seq` stays ordered. */
28
+ tail = Promise.resolve();
29
+ constructor(deps) {
30
+ this.deps = deps;
31
+ this.heartbeatMs = Math.max(1, deps.heartbeatMs ?? 5 * 60_000);
32
+ this.pauseTickMs = Math.max(1, deps.pauseTickMs ?? 60_000);
33
+ this.authFailureDebounceMs = Math.max(0, deps.authFailureDebounceMs ?? 1_000);
34
+ this.now = deps.now ?? Date.now;
35
+ this.timer = deps.timer ?? DEFAULT_TIMER;
36
+ }
37
+ /** The last identity broadcast (null before the first probe). */
38
+ lastIdentity() {
39
+ return this.last;
40
+ }
41
+ /** Fire the startup probe and arm the recurring cadence timer. Idempotent. */
42
+ start() {
43
+ if (this.started || this.stopped)
44
+ return;
45
+ this.started = true;
46
+ this.enqueue("startup");
47
+ this.armRecurring();
48
+ }
49
+ /**
50
+ * Note a capacity pause/resume transition. Re-arms the recurring timer to the
51
+ * matching cadence (~60s paused, ~5min healthy). A no-op when the state is
52
+ * unchanged, so repeated pause ticks don't reset the timer out from under
53
+ * themselves.
54
+ */
55
+ setPaused(paused) {
56
+ if (this.stopped || paused === this.paused)
57
+ return;
58
+ this.paused = paused;
59
+ if (this.started)
60
+ this.armRecurring();
61
+ }
62
+ /**
63
+ * Probe immediately after a 401/403-class engine failure — the single most
64
+ * likely moment the underlying login just changed. Debounced so a burst of
65
+ * auth failures collapses to one probe.
66
+ */
67
+ onAuthFailure() {
68
+ if (this.stopped || !this.started)
69
+ return;
70
+ const t = this.now();
71
+ if (t - this.lastAuthFailureAt < this.authFailureDebounceMs)
72
+ return;
73
+ this.lastAuthFailureAt = t;
74
+ this.enqueue("auth-failure");
75
+ }
76
+ /** Cancel the recurring timer; in-flight probes still settle. */
77
+ stop() {
78
+ this.stopped = true;
79
+ if (this.recurringHandle !== null) {
80
+ this.timer.clearTimer(this.recurringHandle);
81
+ this.recurringHandle = null;
82
+ }
83
+ }
84
+ /** Resolve once every queued probe has settled (test seam). */
85
+ async settle() {
86
+ await this.tail;
87
+ }
88
+ armRecurring() {
89
+ if (this.recurringHandle !== null) {
90
+ this.timer.clearTimer(this.recurringHandle);
91
+ this.recurringHandle = null;
92
+ }
93
+ if (this.stopped)
94
+ return;
95
+ const ms = this.paused ? this.pauseTickMs : this.heartbeatMs;
96
+ this.recurringHandle = this.timer.setTimer(() => this.onRecurringFire(), ms);
97
+ }
98
+ onRecurringFire() {
99
+ if (this.stopped)
100
+ return;
101
+ // Re-arm BEFORE the async probe so the cadence is decoupled from probe
102
+ // latency and stays deterministic under a fake clock.
103
+ this.armRecurring();
104
+ this.enqueue(this.paused ? "pause-tick" : "heartbeat");
105
+ }
106
+ /** Chain a probe onto the serial tail so sequence order is guaranteed. */
107
+ enqueue(trigger) {
108
+ this.tail = this.tail.then(() => this.runProbe(trigger));
109
+ }
110
+ async runProbe(trigger) {
111
+ let identity;
112
+ try {
113
+ identity = await this.deps.probe();
114
+ }
115
+ catch (err) {
116
+ this.deps.onLog?.(`[acc-runner] account probe (${trigger}) failed: ${err?.message ?? String(err)}`);
117
+ return;
118
+ }
119
+ const changed = accountChanged(this.last, identity);
120
+ const seq = ++this.seq;
121
+ const at = this.now();
122
+ if (changed && this.last) {
123
+ this.deps.onLog?.(`[acc-runner] account swap detected (${trigger}): ${this.last.synthetic_account} → ${identity.synthetic_account}`);
124
+ }
125
+ try {
126
+ await this.deps.emit({ identity, trigger, changed, seq, at });
127
+ }
128
+ catch (err) {
129
+ this.deps.onLog?.(`[acc-runner] account identity emit failed (${trigger}): ${err?.message ?? String(err)}`);
130
+ }
131
+ this.last = identity;
132
+ }
133
+ }
134
+ //# sourceMappingURL=account-probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"account-probe.js","sourceRoot":"","sources":["../../src/engines/account-probe.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  import { type ExecaChildProcess } from "execa";
2
2
  import { type AnthropicAuthMode } from "../provider-auth.js";
3
3
  import { type ParsedChatStreamEvent } from "./chat-stream.js";
4
- import type { AccMcpToolBusFence, ConformantAgentEngine, EngineCapabilityMatrix, EngineStreamTurnOptions, RunnerCapacityClass } from "./types.js";
4
+ import type { AccMcpToolBusFence, ConformantAgentEngine, EngineCapabilityMatrix, EnginePlanHint, EngineStreamTurnOptions, RunnerCapacityClass } from "./types.js";
5
5
  /**
6
6
  * Native (built-in) claude tools disabled for a chat-lane turn. The tool bus is
7
7
  * fenced to acc-mcp only, so none of claude's file/shell/web/task tools may run
@@ -67,6 +67,18 @@ export declare function parseChatStreamLine(line: string): ParsedChatStreamEvent
67
67
  * otherwise overloaded (scheduler stops assigning turns).
68
68
  */
69
69
  export declare function classifyCapacityFromLoad(load1: number, cores: number): RunnerCapacityClass;
70
+ /**
71
+ * TOLERANT parse of the claude config JSON into `{ email, plan }`. Reads
72
+ * `oauthAccount.emailAddress` for the email and, for the plan, the first of
73
+ * `oauthAccount.subscriptionType`, top-level `subscriptionType`, or
74
+ * `claudeAiOauth.subscriptionType` that resolves — matching the fields observed
75
+ * across CLI builds. Returns `{ email: null, plan: "unknown" }` for missing
76
+ * file / non-JSON / unexpected shape.
77
+ */
78
+ export declare function parseClaudeAccountConfig(raw: string | null): {
79
+ email: string | null;
80
+ plan: EnginePlanHint;
81
+ };
70
82
  /** Static capability declaration for the claude-code chat reference build. */
71
83
  export declare const CLAUDE_CODE_CHAT_CAPABILITIES: EngineCapabilityMatrix;
72
84
  export interface ClaudeCodeChatDeps {
@@ -83,6 +95,8 @@ export interface ClaudeCodeChatDeps {
83
95
  load1: number;
84
96
  cores: number;
85
97
  };
98
+ /** Read the claude config JSON for account introspection (default: reads `~/.claude.json`). */
99
+ readClaudeConfig?: (env?: NodeJS.ProcessEnv) => string | null;
86
100
  }
87
101
  /**
88
102
  * Build a chat-conformant claude-code engine. Composes the FROZEN batch
@@ -1 +1 @@
1
- {"version":3,"file":"claude-code-chat.d.ts","sourceRoot":"","sources":["../../src/engines/claude-code-chat.ts"],"names":[],"mappings":"AAiCA,OAAO,EAAS,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;AACtD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EAAkB,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC9E,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EACrB,sBAAsB,EAItB,uBAAuB,EACvB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAgBlD,CAAC;AAEF,mGAAmG;AACnG,eAAO,MAAM,4BAA4B,6BAA6B,CAAC;AAEvE;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,kBAAkB,CAEtD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,EAAE,CAmBtG;AAQD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,uBAAuB,GAAG,MAAM,CAAC,UAAU,CAKxE;AAgBD,YAAY,EAAE,qBAAqB,EAAE,CAAC;AAkBtC;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,qBAAqB,GAAG,IAAI,CAoB9E;AASD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAO1F;AA4CD,8EAA8E;AAC9E,eAAO,MAAM,6BAA6B,EAAE,sBAO3C,CAAC;AAEF,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,uBAAuB,KAAK,iBAAiB,CAAC;IACjE,oEAAoE;IACpE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,KAAK,iBAAiB,CAAC;IAC5D,2IAA2I;IAC3I,qBAAqB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/C,uGAAuG;IACvG,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;CACtD;AAYD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,GAAE,kBAAuB,GAAG,qBAAqB,CAU/F;AAED,6DAA6D;AAC7D,eAAO,MAAM,oBAAoB,EAAE,qBAAoD,CAAC"}
1
+ {"version":3,"file":"claude-code-chat.d.ts","sourceRoot":"","sources":["../../src/engines/claude-code-chat.ts"],"names":[],"mappings":"AAmCA,OAAO,EAAS,KAAK,iBAAiB,EAAE,MAAM,OAAO,CAAC;AACtD,OAAO,EAML,KAAK,iBAAiB,EACvB,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EAAkB,KAAK,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAK9E,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EAErB,sBAAsB,EAGtB,cAAc,EAEd,uBAAuB,EACvB,mBAAmB,EACpB,MAAM,YAAY,CAAC;AAIpB;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB,EAAE,SAAS,MAAM,EAgBlD,CAAC;AAEF,mGAAmG;AACnG,eAAO,MAAM,4BAA4B,6BAA6B,CAAC;AAEvE;;;;;GAKG;AACH,wBAAgB,iBAAiB,IAAI,kBAAkB,CAEtD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,aAAa,CAAC,IAAI,GAAE;IAAE,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,MAAM,EAAE,CAmBtG;AAQD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,IAAI,EAAE,uBAAuB,GAAG,MAAM,CAAC,UAAU,CAKxE;AAgBD,YAAY,EAAE,qBAAqB,EAAE,CAAC;AAkBtC;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,qBAAqB,GAAG,IAAI,CAoB9E;AASD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,mBAAmB,CAO1F;AAuDD;;;;;;;GAOG;AACH,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG;IAC5D,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,IAAI,EAAE,cAAc,CAAC;CACtB,CAuBA;AAmED,8EAA8E;AAC9E,eAAO,MAAM,6BAA6B,EAAE,sBAO3C,CAAC;AAEF,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,uBAAuB,KAAK,iBAAiB,CAAC;IACjE,oEAAoE;IACpE,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,CAAC,UAAU,KAAK,iBAAiB,CAAC;IAC5D,2IAA2I;IAC3I,qBAAqB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/C,uGAAuG;IACvG,cAAc,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IACxC,sFAAsF;IACtF,WAAW,CAAC,EAAE,MAAM;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IACrD,+FAA+F;IAC/F,gBAAgB,CAAC,EAAE,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,KAAK,MAAM,GAAG,IAAI,CAAC;CAC/D;AAaD;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,IAAI,GAAE,kBAAuB,GAAG,qBAAqB,CAW/F;AAED,6DAA6D;AAC7D,eAAO,MAAM,oBAAoB,EAAE,qBAAoD,CAAC"}
@@ -31,11 +31,14 @@
31
31
  * `claudeCodeChatEngine` is the wired default built from the production deps.
32
32
  */
33
33
  import os from "node:os";
34
+ import fs from "node:fs";
35
+ import path from "node:path";
34
36
  import { execa } from "execa";
35
- import { resolveAuthMode, describeAuthMode, withAnthropicAuth, } from "../provider-auth.js";
37
+ import { resolveAuthMode, describeAuthMode, withAnthropicAuth, isolatedClaudeConfigDir, claudeConfigIsolationEnabled, } from "../provider-auth.js";
36
38
  import { ACC_MCP_KEY } from "../mcp-spawn.js";
37
39
  import { claudeCodeEngine } from "./claude-code.js";
38
40
  import { streamChatTurn } from "./chat-stream.js";
41
+ import { resolvedAccountIdentity, unknownAccountIdentity, } from "./account-identity.js";
39
42
  const BINARY = "claude";
40
43
  /**
41
44
  * Native (built-in) claude tools disabled for a chat-lane turn. The tool bus is
@@ -238,6 +241,118 @@ async function credentialProbeImpl(deps) {
238
241
  detail: `${describeAuthMode(mode)}; repo-write ${repoWrite ? "granted (full tier)" : "absent (draft-only tier)"}`,
239
242
  };
240
243
  }
244
+ /* ------------------------------------------------------------------ *
245
+ * AS-1 (ACCOUNT-PROBE): claude-code account introspection.
246
+ *
247
+ * The claude CLI persists its logged-in account into the config JSON it
248
+ * maintains (`~/.claude.json`, or `<CLAUDE_CONFIG_DIR>/.claude.json` when the
249
+ * operator/isolation pins a config dir): an `oauthAccount.emailAddress` plus, on
250
+ * builds that store it, a subscription-type field. Reading that artifact IS the
251
+ * CLI's auth introspection — there is no stable `claude whoami` to spawn, and a
252
+ * file read is cheaper + can't be rate-limited. In api-key mode a bare key
253
+ * carries no email, so we honestly report `account_email: null` + `plan_hint:
254
+ * "api"`. The parse is TOLERANT (the codex tolerant-parser lesson): any shape
255
+ * drift degrades to `unknown`, never a throw.
256
+ * ------------------------------------------------------------------ */
257
+ /** Map a raw claude subscription-type string to the coarse plan hint. */
258
+ function planHintFromSubscription(raw) {
259
+ if (typeof raw !== "string")
260
+ return "unknown";
261
+ const v = raw.trim().toLowerCase();
262
+ if (v === "max" || v.startsWith("max"))
263
+ return "max";
264
+ if (v === "pro" || v.startsWith("pro"))
265
+ return "pro";
266
+ if (v === "free")
267
+ return "free";
268
+ if (v === "team")
269
+ return "team";
270
+ if (v === "enterprise" || v === "enterprise_plus")
271
+ return "enterprise";
272
+ return "unknown";
273
+ }
274
+ /**
275
+ * TOLERANT parse of the claude config JSON into `{ email, plan }`. Reads
276
+ * `oauthAccount.emailAddress` for the email and, for the plan, the first of
277
+ * `oauthAccount.subscriptionType`, top-level `subscriptionType`, or
278
+ * `claudeAiOauth.subscriptionType` that resolves — matching the fields observed
279
+ * across CLI builds. Returns `{ email: null, plan: "unknown" }` for missing
280
+ * file / non-JSON / unexpected shape.
281
+ */
282
+ export function parseClaudeAccountConfig(raw) {
283
+ const s = (raw ?? "").trim();
284
+ if (!s.startsWith("{"))
285
+ return { email: null, plan: "unknown" };
286
+ let obj;
287
+ try {
288
+ obj = JSON.parse(s);
289
+ }
290
+ catch {
291
+ return { email: null, plan: "unknown" };
292
+ }
293
+ if (typeof obj !== "object" || obj === null)
294
+ return { email: null, plan: "unknown" };
295
+ const rec = obj;
296
+ const oauth = (typeof rec.oauthAccount === "object" && rec.oauthAccount !== null
297
+ ? rec.oauthAccount
298
+ : {});
299
+ const claudeAi = (typeof rec.claudeAiOauth === "object" && rec.claudeAiOauth !== null
300
+ ? rec.claudeAiOauth
301
+ : {});
302
+ const rawEmail = oauth.emailAddress ?? oauth.email ?? claudeAi.emailAddress;
303
+ const email = typeof rawEmail === "string" && rawEmail.trim() ? rawEmail.trim() : null;
304
+ const plan = planHintFromSubscription(oauth.subscriptionType ?? rec.subscriptionType ?? claudeAi.subscriptionType);
305
+ return { email, plan };
306
+ }
307
+ /** Resolve the config-JSON path the claude CLI is expected to maintain. */
308
+ function claudeConfigPath(env) {
309
+ const explicit = (env.CLAUDE_CONFIG_DIR ?? "").trim();
310
+ if (explicit)
311
+ return path.join(explicit, ".claude.json");
312
+ // Mirror provider-auth's isolation: when opted in, the CLI writes under the
313
+ // runner-owned config dir rather than $HOME.
314
+ if (claudeConfigIsolationEnabled(env)) {
315
+ return path.join(isolatedClaudeConfigDir(env), ".claude.json");
316
+ }
317
+ return path.join(os.homedir(), ".claude.json");
318
+ }
319
+ /** Default introspection: read the claude config JSON, or null when absent/unreadable. */
320
+ function defaultReadClaudeConfig(env = process.env) {
321
+ try {
322
+ return fs.readFileSync(claudeConfigPath(env), "utf8");
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ async function accountIdentityImpl(deps) {
329
+ const channel = claudeCodeEngine.authMode; // "api-key-or-oauth"
330
+ const mode = deps.resolveAuth(process.env);
331
+ if (mode === "api-key") {
332
+ // A bare API key carries no email; report the billing channel honestly.
333
+ return unknownAccountIdentity({
334
+ engineId: claudeCodeEngine.id,
335
+ channel,
336
+ planHint: "api",
337
+ detail: "api-key channel (ANTHROPIC_API_KEY set); no consumer account email to introspect",
338
+ });
339
+ }
340
+ const { email, plan } = parseClaudeAccountConfig(deps.readClaudeConfig(process.env));
341
+ if (!email) {
342
+ return unknownAccountIdentity({
343
+ engineId: claudeCodeEngine.id,
344
+ channel,
345
+ detail: "interactive-session channel; no logged-in account found in the claude config",
346
+ });
347
+ }
348
+ return resolvedAccountIdentity({
349
+ engineId: claudeCodeEngine.id,
350
+ channel,
351
+ email,
352
+ planHint: plan,
353
+ detail: `interactive-session login ${email}${plan === "unknown" ? "" : ` (${plan})`}`,
354
+ });
355
+ }
241
356
  /**
242
357
  * Default repo-write probe: `gh auth status` exit 0. A companion runner that can
243
358
  * push / open a PR authorises the `full` tier; one that only holds a model
@@ -268,6 +383,7 @@ const DEFAULT_DEPS = {
268
383
  hasInteractiveSession: async () => true,
269
384
  repoWriteProbe: defaultRepoWriteProbe,
270
385
  loadSampler: () => ({ load1: os.loadavg()[0] ?? 0, cores: os.cpus().length }),
386
+ readClaudeConfig: defaultReadClaudeConfig,
271
387
  };
272
388
  /**
273
389
  * Build a chat-conformant claude-code engine. Composes the FROZEN batch
@@ -285,6 +401,7 @@ export function createClaudeCodeChatEngine(deps = {}) {
285
401
  accMcpToolBusOnly,
286
402
  credentialProbe: () => credentialProbeImpl(d),
287
403
  classifyCapacity: () => classifyCapacityImpl(d),
404
+ accountIdentity: () => accountIdentityImpl(d),
288
405
  };
289
406
  }
290
407
  /** The wired default chat-conformant claude-code adapter. */