tokenmaxxing 1.38.0 → 1.40.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.
@@ -3,8 +3,8 @@ import { dirname, join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
5
  import * as kc from "./keychain.ts";
6
- import { credDir, keychain as kcNames, namespacedCredService, paths } from "./paths.ts";
7
- import { CredentialBlobSchema, ErrnoSchema } from "./types.ts";
6
+ import { keychain as kcNames, namespacedCredService, storeDirFor } from "./paths.ts";
7
+ import { CredentialBlobSchema, ErrnoSchema, type OAuthCreds } from "./types.ts";
8
8
 
9
9
  const CredTargetSchema = z.discriminatedUnion("kind", [
10
10
  z.object({ kind: z.literal("keychain"), service: z.string(), account: z.string() }),
@@ -14,8 +14,6 @@ export type CredTarget = z.infer<typeof CredTargetSchema>;
14
14
 
15
15
  const darwin = process.platform === "darwin";
16
16
 
17
- const BlobRecordSchema = z.record(z.string(), z.unknown());
18
-
19
17
  export async function readItem(t: CredTarget): Promise<string | null> {
20
18
  if (t.kind === "keychain") return kc.readItem(t);
21
19
  try {
@@ -43,31 +41,22 @@ export async function deleteItem(t: CredTarget): Promise<boolean> {
43
41
  }
44
42
  }
45
43
 
46
- export function liveTarget(): CredTarget {
44
+ export function isolatedTarget(configDirRaw: string): CredTarget {
47
45
  return darwin
48
- ? { kind: "keychain", service: kcNames.service, account: kcNames.account }
49
- : { kind: "file", path: join(credDir(), ".credentials.json") };
46
+ ? { kind: "keychain", service: namespacedCredService(configDirRaw), account: kcNames.account }
47
+ : { kind: "file", path: join(configDirRaw, ".credentials.json") };
50
48
  }
51
49
 
52
- export function parkedTarget(itemName: string): CredTarget {
53
- return darwin
54
- ? { kind: "keychain", service: itemName, account: kcNames.account }
55
- : { kind: "file", path: join(paths.credsDir, `${itemName}.json`) };
50
+ export function storeTarget(accountId: string): CredTarget {
51
+ return isolatedTarget(storeDirFor(accountId));
56
52
  }
57
53
 
58
- export function isolatedTarget(configDirRaw: string): CredTarget {
59
- return darwin
60
- ? { kind: "keychain", service: namespacedCredService(configDirRaw), account: kcNames.account }
61
- : { kind: "file", path: join(configDirRaw, ".credentials.json") };
54
+ export async function readStore(accountId: string): Promise<OAuthCreds | null> {
55
+ const raw = await readItem(storeTarget(accountId));
56
+ return raw == null ? null : CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
62
57
  }
63
58
 
64
59
  export function claudeAiOauthOnly(fullBlobRaw: string): string {
65
60
  const b = CredentialBlobSchema.parse(JSON.parse(fullBlobRaw));
66
61
  return JSON.stringify({ claudeAiOauth: b.claudeAiOauth });
67
62
  }
68
-
69
- export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
70
- const base = currentLiveRaw == null ? {} : BlobRecordSchema.parse(JSON.parse(currentLiveRaw));
71
- base["claudeAiOauth"] = freshClaudeAiOauth;
72
- return JSON.stringify(base);
73
- }
package/src/lib/decide.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { withLock } from "./lock.ts";
3
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, saveAccounts, saveDepletedWait } from "./state.ts";
3
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadLastSwapAt, saveAccounts } from "./state.ts";
4
4
  import { isExhausted, limitWindows, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
5
5
  import { familyTokens } from "./usage.ts";
6
6
  import { log } from "./log.ts";
@@ -43,12 +43,12 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
43
43
  return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
44
44
  }
45
45
 
46
- export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
46
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
47
47
  const activeId = p.liveId();
48
48
 
49
49
  const lastSwapAt = loadLastSwapAt(p.pool);
50
50
  if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
51
- return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
51
+ return { swapped: false, account: null, reason: "post-swap-cooldown" };
52
52
  }
53
53
 
54
54
  const cfg = loadConfig();
@@ -58,11 +58,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
58
58
  const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
59
59
  const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
60
60
 
61
- if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg) })) {
62
- if (!observed) {
63
- const replay = depletedReplay(p, now);
64
- if (replay) return replay;
65
- }
61
+ if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg), seats: null })) {
66
62
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
67
63
  }
68
64
 
@@ -89,12 +85,16 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
89
85
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
90
86
  }
91
87
 
92
- const obs2 = active ? await p.observeLive(active, cfg, now, { probe: false }) : null;
93
- if (active && obs2 && (active.lastUsageAt == null || obs2.at > active.lastUsageAt)) {
94
- active.windows = p.mergeWindows(obs2.windows, active.windows);
95
- active.lastUsageAt = obs2.at;
96
- saveAccounts(p.pool, idx);
88
+ let obs2: Observation | null = null;
89
+ for (const a of idx.accounts) {
90
+ const obs = await p.observeLive(a, cfg, now, { probe: false });
91
+ if (a === active) obs2 = obs;
92
+ if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
93
+ a.windows = p.mergeWindows(obs.windows, a.windows);
94
+ a.lastUsageAt = obs.at;
95
+ }
97
96
  }
97
+ saveAccounts(p.pool, idx);
98
98
 
99
99
  const families = p.gatedFamilies(cfg);
100
100
  const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
@@ -102,18 +102,22 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
102
102
  const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
103
103
  const family = enforced?.family ?? null;
104
104
  const switchFamilies = screened == null ? null : family != null && !screened.includes(family) ? [...screened, family] : screened;
105
+ const present = p.presence();
106
+ const seats = p.seats === "shared" ? present : null;
105
107
 
106
- const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies });
107
- if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families }) && !(enforced && seatExhausted)) {
108
- return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
108
+ const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies, seats });
109
+ if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families, seats }) && !(enforced && seatExhausted)) {
110
+ return { swapped: false, account: null, reason: "raced-already-swapped" };
111
+ }
112
+ if (p.seats === "shared" && !canRespawn) {
113
+ return { swapped: false, account: null, reason: "needs-respawn" };
109
114
  }
110
115
 
111
116
  const seatOf = (cur: { activeId: string | null; accounts: Account[] }): Account | null =>
112
117
  cur.accounts.find((a) => a.id === id2) ?? cur.accounts.find((a) => a.id === cur.activeId) ?? null;
113
118
 
114
- const present = p.presentIds();
115
119
  const rejected = new Set<string>();
116
- const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === id2 || !present.has(a.id)));
120
+ const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (p.seats === "shared" || a.id === id2 || !present.has(a.id)));
117
121
  const skipOrThrow = (e: unknown, candidate: Account): void => {
118
122
  if (p.classifySwapError(e) === "fatal") throw e;
119
123
  rejected.add(candidate.id);
@@ -122,7 +126,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
122
126
  while (true) {
123
127
  const cur = loadAccounts(p.pool);
124
128
  const seat = seatOf(cur);
125
- const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies };
129
+ const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
126
130
  const best = pickBest(usable(cur.accounts), ctx);
127
131
  if (!best) break;
128
132
  try {
@@ -143,7 +147,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
143
147
  while (true) {
144
148
  const fresh = loadAccounts(p.pool);
145
149
  const current = seatOf(fresh);
146
- const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies };
150
+ const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
147
151
  const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
148
152
  const other = pickEarliestReset(usable(fresh.accounts), ctx);
149
153
 
@@ -159,10 +163,6 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
159
163
  }
160
164
 
161
165
  const isCurrent = target.id === (current?.id ?? null);
162
- if (!isCurrent && !anticipatory) {
163
- log("decide.depleted_no_park", { account: target.id.slice(0, 8), waitUntil });
164
- return { swapped: false, account: null, reason: "all-depleted", waitUntil };
165
- }
166
166
  if (!isCurrent) {
167
167
  try {
168
168
  await p.swap(target);
@@ -171,19 +171,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), antici
171
171
  continue;
172
172
  }
173
173
  }
174
- saveDepletedWait({ waitUntil, id: target.id, ts: now });
175
174
  log("decide.depleted_wait", { account: target.id.slice(0, 8), waitUntil });
176
175
  return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
177
176
  }
178
177
  });
179
178
  }
180
-
181
- function depletedReplay(p: Provider, now: number): SwapDecision | null {
182
- if (!p.waitsWhenDepleted) return null;
183
- const rec = loadDepletedWait();
184
- if (!rec || rec.waitUntil <= now) return null;
185
- const account = loadAccounts(p.pool).accounts.find((a) => a.id === rec.id) ?? null;
186
- if (!account) return null;
187
- if (account.id !== p.liveId()) return null;
188
- return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
189
- }
package/src/lib/oauth.ts CHANGED
@@ -1,19 +1,9 @@
1
- import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
1
+ import { http, safeErrorDetail } from "./http.ts";
2
2
  import { env } from "./paths.ts";
3
- import { JsonTextSchema, ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
3
+ import { JsonTextSchema, ProfileResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
4
4
 
5
- const TOKEN_URL = env("TOKENMAXXING_OAUTH_TOKEN_URL", "https://platform.claude.com/v1/oauth/token");
6
- const CLIENT_ID = env("TOKENMAXXING_OAUTH_CLIENT_ID", "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
7
5
  const PROFILE_URL = env("TOKENMAXXING_OAUTH_PROFILE_URL", "https://api.anthropic.com/api/oauth/profile");
8
6
 
9
- const DEFAULT_SCOPES = [
10
- "user:profile",
11
- "user:inference",
12
- "user:sessions:claude_code",
13
- "user:mcp_servers",
14
- "user:file_upload",
15
- ];
16
-
17
7
  export class InvalidGrantError extends Error {
18
8
  constructor(public readonly detail: string) {
19
9
  super(`invalid_grant: ${detail}`);
@@ -21,13 +11,6 @@ export class InvalidGrantError extends Error {
21
11
  }
22
12
  }
23
13
 
24
- export class RefreshRejectedError extends Error {
25
- constructor(public readonly status: number, public readonly detail: string) {
26
- super(`token refresh rejected (HTTP ${status}): ${detail}`);
27
- this.name = "RefreshRejectedError";
28
- }
29
- }
30
-
31
14
  export class IdentityUnavailableError extends Error {
32
15
  constructor(public readonly status: number | null, public readonly detail: string) {
33
16
  super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
@@ -46,55 +29,6 @@ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTie
46
29
  return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
47
30
  }
48
31
 
49
- export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
50
- if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
51
- const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
52
- const body = {
53
- grant_type: "refresh_token",
54
- refresh_token: creds.refreshToken,
55
- client_id: CLIENT_ID,
56
- scope,
57
- };
58
-
59
- let res: Response;
60
- try {
61
- res = await http.post(TOKEN_URL, {
62
- headers: { "Content-Type": "application/json" },
63
- body: JSON.stringify(body),
64
- signal,
65
- });
66
- } catch (e) {
67
- throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
68
- }
69
-
70
- const text = await res.text();
71
- if (!res.ok) {
72
- const detail = safeErrorDetail({ text });
73
- if (res.status === 400 && oauthErrorCode({ text }) === "invalid_grant") throw new InvalidGrantError(detail);
74
- if (res.status >= 400 && res.status < 500) throw new RefreshRejectedError(res.status, detail);
75
- throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
76
- }
77
-
78
- const parsed = RefreshResponseSchema.safeParse(JsonTextSchema.safeParse(text).data);
79
- if (!parsed.success) {
80
- throw new Error(`token endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
81
- }
82
- const json = parsed.data;
83
-
84
- const expiresIn = json.expires_in ?? 8 * 3600;
85
- const refreshExpiresIn = json.refresh_token_expires_in;
86
-
87
- return {
88
- ...creds,
89
- accessToken: json.access_token,
90
- refreshToken: json.refresh_token ?? creds.refreshToken,
91
- expiresAt: now + expiresIn * 1000,
92
- refreshTokenExpiresAt:
93
- refreshExpiresIn != null ? now + refreshExpiresIn * 1000 : creds.refreshTokenExpiresAt,
94
- scopes: json.scope ? json.scope.split(" ").filter(Boolean) : creds.scopes,
95
- };
96
- }
97
-
98
32
  export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now = Date.now()): boolean {
99
33
  return !creds.expiresAt || creds.expiresAt - now <= skewMs;
100
34
  }
package/src/lib/paths.ts CHANGED
@@ -15,22 +15,21 @@ const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
15
15
  export const paths = {
16
16
  home: TM_HOME,
17
17
  configJson: join(TM_HOME, "config.json"),
18
- usageJson: join(TM_HOME, "usage.json"),
19
- depletedJson: join(TM_HOME, "depleted.json"),
18
+ usageDir: join(TM_HOME, "usage"),
20
19
  respawnDir: join(TM_HOME, "respawn"),
20
+ presenceDir: join(TM_HOME, "live"),
21
21
  binDir: join(TM_HOME, "bin"),
22
22
  supervisorLink: join(TM_HOME, "bin", "claude"),
23
23
  logFile: join(TM_HOME, "tokenmaxxing.log"),
24
24
  onboardDir: join(TM_HOME, "onboard"),
25
25
  sampleDir: join(TM_HOME, "sample"),
26
- credsDir: join(TM_HOME, "creds"),
26
+ storesDir: join(TM_HOME, "stores"),
27
27
  setupTokensJson: join(TM_HOME, "setup-tokens.json"),
28
28
  setupTokenDir: join(TM_HOME, "setup-token"),
29
29
  cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
30
30
  cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
31
31
  cloudLockFile: join(TM_HOME, "cloud", "lock"),
32
32
 
33
- claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
34
33
  claudeSettings: env(
35
34
  "TOKENMAXXING_CLAUDE_SETTINGS",
36
35
  join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
@@ -43,7 +42,7 @@ export const paths = {
43
42
 
44
43
  export const claudePool = {
45
44
  accountsJson: join(TM_HOME, "accounts.json"),
46
- lastSwapJson: join(TM_HOME, "lastswap.json"),
45
+ lastSwapJson: null,
47
46
  lockFile: join(TM_HOME, "lock"),
48
47
  } as const;
49
48
 
@@ -53,7 +52,7 @@ export const codexPool = {
53
52
  lockFile: join(TM_HOME, "codex-lock"),
54
53
  } as const;
55
54
 
56
- export type PoolPaths = { accountsJson: string; lastSwapJson: string; lockFile: string };
55
+ export type PoolPaths = { accountsJson: string; lastSwapJson: string | null; lockFile: string };
57
56
 
58
57
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
59
58
 
@@ -73,18 +72,29 @@ export function codexCredItemFor(accountId: string): string {
73
72
  }
74
73
 
75
74
  export const keychain = {
76
- service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
77
75
  account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
78
76
  } as const;
79
77
 
80
- export function credItemFor(accountUuid: string): string {
81
- return `tokenmaxxing-cred-${accountUuid.slice(0, 8)}`;
78
+ export function shortId(accountId: string): string {
79
+ return accountId.slice(0, 8);
82
80
  }
83
81
 
84
- export function credDir(): string {
85
- const secure = process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
86
- if (secure !== undefined) return (secure || join(HOME, ".claude")).normalize("NFC");
87
- return paths.claudeDir;
82
+ export function storeDirFor(accountId: string): string {
83
+ return join(paths.storesDir, shortId(accountId));
84
+ }
85
+
86
+ export function sampleDirFor(accountId: string, suffix = ""): string {
87
+ return join(paths.sampleDir, `${shortId(accountId)}${suffix}`);
88
+ }
89
+
90
+ export function usageJsonFor(accountId: string): string {
91
+ return join(paths.usageDir, `${shortId(accountId)}.json`);
92
+ }
93
+
94
+ export function seatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
95
+ const store = env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
96
+ if (store == null || store === "") return null;
97
+ return accountIds.find((id) => storeDirFor(id) === store) ?? null;
88
98
  }
89
99
 
90
100
  export function namespacedCredService(configDirRaw: string): string {
package/src/lib/picker.ts CHANGED
@@ -15,6 +15,7 @@ const PickCtxSchema = z.object({
15
15
  thresholds: ThresholdsSchema,
16
16
  currentId: z.string().nullable(),
17
17
  families: z.array(z.string()).nullable(),
18
+ seats: z.map(z.string(), z.number()).nullable(),
18
19
  });
19
20
  export type PickCtx = z.infer<typeof PickCtxSchema>;
20
21
 
@@ -93,7 +94,14 @@ export function pacePressure(a: Account, now: number): number {
93
94
  return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
94
95
  }
95
96
 
97
+ export function seatHeadroom(a: Account, ctx: PickCtx): number {
98
+ const session = sessionWindow(a);
99
+ if (session == null) return Number.NEGATIVE_INFINITY;
100
+ return (ctx.thresholds.session - liveUsed(session, ctx.now)) / ((ctx.seats?.get(a.id) ?? 0) + 1);
101
+ }
102
+
96
103
  const swapPreference = (ctx: PickCtx) => [
104
+ ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
97
105
  (a: Account) => -pacePressure(a, ctx.now),
98
106
  (a: Account) => weeklyExpiry(a, ctx.now),
99
107
  (a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
@@ -0,0 +1,67 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "./atomic.ts";
5
+ import { pidExists, pidStartTime } from "./proc.ts";
6
+
7
+ const PresenceSchema = z.object({
8
+ accountId: z.string(),
9
+ pid: z.number(),
10
+ startedAt: z.string(),
11
+ });
12
+
13
+ export function writePresence(input: { dir: string; id: string; accountId: string; pid: number }): void {
14
+ const startedAt = pidStartTime(input.pid);
15
+ if (startedAt == null) throw new Error(`could not read pid ${input.pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
16
+ mkdirSync(input.dir, { recursive: true });
17
+ writeFileAtomic(join(input.dir, input.id), JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: input.pid, startedAt })));
18
+ }
19
+
20
+ export function clearPresence(input: { dir: string; id: string }): void {
21
+ rmSync(join(input.dir, input.id), { force: true });
22
+ }
23
+
24
+ const LivingPresenceSchema = z.object({ id: z.string(), accountId: z.string() });
25
+ export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
26
+
27
+ export function livingPresences(dir: string): LivingPresence[] {
28
+ const living: LivingPresence[] = [];
29
+ if (!existsSync(dir)) return living;
30
+ for (const name of readdirSync(dir)) {
31
+ const file = join(dir, name);
32
+ let raw: string;
33
+ try {
34
+ raw = readFileSync(file, "utf8");
35
+ } catch (e) {
36
+ const errno = z.object({ code: z.string() }).safeParse(e);
37
+ if (errno.success && errno.data.code === "ENOENT") continue;
38
+ throw e;
39
+ }
40
+ const parsed = PresenceSchema.safeParse((() => {
41
+ try {
42
+ return JSON.parse(raw);
43
+ } catch {
44
+ return null;
45
+ }
46
+ })());
47
+ if (!parsed.success) {
48
+ throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
49
+ }
50
+ const observed = pidStartTime(parsed.data.pid);
51
+ if (observed !== parsed.data.startedAt) {
52
+ if (observed == null && pidExists(parsed.data.pid)) {
53
+ throw new Error(`ps could not read the start time of live pid ${parsed.data.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING session`);
54
+ }
55
+ rmSync(file, { force: true });
56
+ continue;
57
+ }
58
+ living.push(LivingPresenceSchema.parse({ id: name, accountId: parsed.data.accountId }));
59
+ }
60
+ return living;
61
+ }
62
+
63
+ export function seatCounts(dir: string): Map<string, number> {
64
+ const counts = new Map<string, number>();
65
+ for (const presence of livingPresences(dir)) counts.set(presence.accountId, (counts.get(presence.accountId) ?? 0) + 1);
66
+ return counts;
67
+ }
@@ -12,12 +12,10 @@ export type Provider = {
12
12
  name: "claude" | "codex";
13
13
  flag: string;
14
14
  pool: PoolPaths;
15
+ seats: "shared" | "live";
15
16
  waitsWhenDepleted: boolean;
16
- switchMargin: number;
17
- switchNote: string;
18
17
  liveId(): string | null;
19
- liveOwner(): Promise<string | null>;
20
- presentIds(): Set<string>;
18
+ presence(): Map<string, number>;
21
19
  gatedFamilies(cfg: Config): string[] | null;
22
20
  observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
23
21
  samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;