tokenmaxxing 0.19.0 → 0.21.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.
Files changed (59) hide show
  1. package/DESIGN.md +34 -23
  2. package/README.md +4 -4
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +1 -0
  5. package/src/cli/auth.ts +25 -14
  6. package/src/cli/check.ts +3 -2
  7. package/src/cli/codexadd.ts +44 -40
  8. package/src/cli/codexinit.ts +59 -12
  9. package/src/cli/codexswitch.ts +15 -1
  10. package/src/cli/config.ts +10 -1
  11. package/src/cli/doctor.ts +3 -3
  12. package/src/cli/init.ts +54 -32
  13. package/src/cli/onboard.ts +62 -45
  14. package/src/cli/render.ts +0 -16
  15. package/src/cli/rm.ts +40 -2
  16. package/src/cli/serve.ts +650 -78
  17. package/src/cli/status.ts +69 -23
  18. package/src/cli/switch.ts +54 -19
  19. package/src/entries/codexstophook.ts +123 -4
  20. package/src/entries/codexsupervisor.ts +87 -13
  21. package/src/entries/sessionstart.ts +1 -1
  22. package/src/entries/statusline.ts +56 -20
  23. package/src/entries/stophook.ts +23 -9
  24. package/src/entries/supervisor.ts +134 -18
  25. package/src/lib/atomic.ts +28 -6
  26. package/src/lib/claudebin.ts +2 -2
  27. package/src/lib/claudejson.ts +5 -5
  28. package/src/lib/claudelock.ts +112 -37
  29. package/src/lib/codexauth.ts +10 -2
  30. package/src/lib/codexbin.ts +1 -1
  31. package/src/lib/codexdecide.ts +149 -19
  32. package/src/lib/codexpick.ts +17 -6
  33. package/src/lib/codexpresence.ts +59 -21
  34. package/src/lib/codexsample.ts +17 -8
  35. package/src/lib/codexswap.ts +10 -1
  36. package/src/lib/credstore.ts +6 -2
  37. package/src/lib/decide.ts +114 -42
  38. package/src/lib/install.ts +125 -17
  39. package/src/lib/keychain.ts +41 -15
  40. package/src/lib/lock.ts +57 -35
  41. package/src/lib/log.ts +36 -7
  42. package/src/lib/oauth.ts +18 -11
  43. package/src/lib/paths.ts +17 -11
  44. package/src/lib/picker.ts +11 -3
  45. package/src/lib/proc.ts +37 -0
  46. package/src/lib/sample.ts +91 -31
  47. package/src/lib/sessions.ts +23 -1
  48. package/src/lib/settings.ts +59 -18
  49. package/src/lib/slackbridge.ts +583 -76
  50. package/src/lib/slackstate.ts +159 -12
  51. package/src/lib/slackstream.ts +127 -21
  52. package/src/lib/state.ts +131 -35
  53. package/src/lib/swap.ts +109 -47
  54. package/src/lib/types.ts +79 -37
  55. package/src/lib/usage.ts +114 -16
  56. package/src/main.ts +61 -7
  57. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  58. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  59. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/lock.ts CHANGED
@@ -2,57 +2,79 @@
2
2
  // descriptor (macOS ships no flock(1) binary, and one codepath serves both
3
3
  // platforms). The lock is released when we close the fd (explicitly or on
4
4
  // process exit).
5
+ //
6
+ // The acquire is NON-BLOCKING (LOCK_EX|LOCK_NB) with an async retry loop: a
7
+ // blocking LOCK_EX from this runtime freezes the whole event loop, and in
8
+ // `xx serve` (many actors, one process) a second actor's blocking acquire
9
+ // would stop the holder from ever resuming to release - a true single-process
10
+ // deadlock; a cross-process holder would freeze the daemon for its whole
11
+ // critical section (adversarial review catch, 2026-07-19). EWOULDBLOCK is
12
+ // told apart from real failures via errno, so a bad fd still fails fast
13
+ // instead of spinning.
5
14
 
6
15
  import { closeSync, mkdirSync, openSync } from "node:fs";
7
16
  import { dirname } from "node:path";
8
- import { dlopen, FFIType, suffix } from "bun:ffi";
17
+ import { dlopen, FFIType, read } from "bun:ffi";
18
+ import { delay } from "es-toolkit";
9
19
 
10
20
  // sys/file.h (identical on darwin + linux): LOCK_SH=1 LOCK_EX=2 LOCK_NB=4 LOCK_UN=8
11
21
  const LOCK_EX = 2;
22
+ const LOCK_NB = 4;
12
23
  const LOCK_UN = 8;
24
+ // errno.h: EWOULDBLOCK/EAGAIN is 35 on darwin, 11 on linux; EINTR is 4 on both.
25
+ const EAGAIN = process.platform === "darwin" ? 35 : 11;
26
+ const EINTR = 4;
27
+ const RETRY_MS = 75;
13
28
 
14
- let _flock: ((fd: number, op: number) => number) | null = null;
29
+ const FLOCK_DEF = { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } } as const;
15
30
 
16
- function flockFn(): (fd: number, op: number) => number {
17
- if (_flock) return _flock;
18
- // darwin: flock(2) is in libSystem; the bare name resolves via the dyld shared
19
- // cache even though no physical .dylib exists on disk (verified 2026-07-08).
20
- // linux: glibc's libc.so.6 (verified in-container 2026-07-09, arm64).
21
- const candidates =
22
- process.platform === "darwin"
23
- ? ["libSystem.B.dylib", "/usr/lib/libSystem.B.dylib", `libc.${suffix}`]
24
- : [`libc.so.6`, `libc.${suffix}`];
25
- let lib: ReturnType<typeof dlopen> | null = null;
26
- let lastErr: unknown;
27
- for (const path of candidates) {
28
- try {
29
- lib = dlopen(path, {
30
- flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
31
- });
32
- break;
33
- } catch (e) {
34
- lastErr = e;
35
- }
31
+ // darwin: flock(2) + __error live in libSystem; the bare name resolves via the
32
+ // dyld shared cache even though no physical .dylib exists on disk (verified
33
+ // 2026-07-08). linux: glibc's libc.so.6 + __errno_location (verified
34
+ // in-container 2026-07-09, arm64). Exactly one verified path per platform -
35
+ // a broken environment fails fast instead of falling through a guess list.
36
+ function loadLibc() {
37
+ if (process.platform === "darwin") {
38
+ const lib = dlopen("libSystem.B.dylib", { ...FLOCK_DEF, __error: { args: [], returns: FFIType.ptr } });
39
+ return { flock: lib.symbols.flock, errnoPtr: lib.symbols.__error };
36
40
  }
37
- if (!lib) throw new Error(`could not load flock(2): ${String(lastErr)}`);
38
- const sym = lib.symbols.flock as unknown as (fd: number, op: number) => number;
39
- _flock = sym;
40
- return _flock;
41
+ const lib = dlopen("libc.so.6", { ...FLOCK_DEF, __errno_location: { args: [], returns: FFIType.ptr } });
42
+ return { flock: lib.symbols.flock, errnoPtr: lib.symbols.__errno_location };
43
+ }
44
+
45
+ let _libc: ReturnType<typeof loadLibc> | null = null;
46
+
47
+ function libc(): ReturnType<typeof loadLibc> {
48
+ _libc ??= loadLibc();
49
+ return _libc;
50
+ }
51
+
52
+ function currentErrno(): number {
53
+ const p = libc().errnoPtr();
54
+ return p == null ? -1 : read.i32(p, 0);
41
55
  }
42
56
 
43
57
  /**
44
- * Acquire an exclusive advisory lock on `lockPath`, blocking until available.
45
- * Returns a handle whose release() drops the lock. The lock is process-scoped
46
- * (held via an open fd) so racing hooks in separate processes serialize.
58
+ * Acquire an exclusive advisory lock on `lockPath`, waiting (without blocking
59
+ * the event loop) until available. Returns a handle whose release() drops the
60
+ * lock. The lock is fd-scoped, so racing hooks in separate processes serialize
61
+ * and same-process actors queue on the retry loop.
47
62
  */
48
- export function acquireLock(lockPath: string): { release: () => void } {
63
+ export async function acquireLock(lockPath: string): Promise<{ release: () => void }> {
49
64
  mkdirSync(dirname(lockPath), { recursive: true });
50
65
  const fd = openSync(lockPath, "a", 0o600);
51
- const flock = flockFn();
52
- const rc = flock(fd, LOCK_EX);
53
- if (rc !== 0) {
66
+ const { flock } = libc();
67
+ try {
68
+ while (flock(fd, LOCK_EX | LOCK_NB) !== 0) {
69
+ const errno = currentErrno();
70
+ if (errno !== EAGAIN && errno !== EINTR) {
71
+ throw new Error(`flock LOCK_EX|LOCK_NB failed on ${lockPath} (errno ${errno})`);
72
+ }
73
+ await delay(RETRY_MS);
74
+ }
75
+ } catch (e) {
54
76
  closeSync(fd);
55
- throw new Error(`flock LOCK_EX failed on ${lockPath} (rc=${rc})`);
77
+ throw e;
56
78
  }
57
79
  let released = false;
58
80
  const release = () => {
@@ -69,7 +91,7 @@ export function acquireLock(lockPath: string): { release: () => void } {
69
91
 
70
92
  /** Run `fn` while holding `lockPath`; always releases, even on throw. */
71
93
  export async function withLock<T>(lockPath: string, fn: () => Promise<T> | T): Promise<T> {
72
- const held = acquireLock(lockPath);
94
+ const held = await acquireLock(lockPath);
73
95
  try {
74
96
  return await fn();
75
97
  } finally {
package/src/lib/log.ts CHANGED
@@ -1,28 +1,57 @@
1
1
  // Append-only logging. NEVER logs secret material - callers must pass only
2
2
  // non-secret context (account uuids/emails, percentages, status strings).
3
3
 
4
- import { appendFileSync, mkdirSync } from "node:fs";
4
+ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
5
5
  import { dirname } from "node:path";
6
6
  import { z } from "zod";
7
7
  import { paths } from "./paths.ts";
8
8
 
9
+ const LOG_MAX_BYTES = 5_000_000;
10
+
9
11
  /** Redact anything that looks like a token so an accidental pass-through can't leak. */
10
- export function redact(s: string): string {
12
+ function redact(s: string): string {
11
13
  return s
12
14
  // JWT-ish / long opaque tokens
13
15
  .replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
14
16
  .replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}...(${m.length})`);
15
17
  }
16
18
 
19
+ let echo: ((input: { event: string; parts: string }) => void) | null = null;
20
+
21
+ /** Tee every subsequent log() line to a terminal printer. Only the serve
22
+ * daemon opts in: hooks and the statusline own their stdout protocol, so the
23
+ * echo stays off by default. The printer receives the same redacted parts the
24
+ * file line gets. */
25
+ export function setLogEcho(input: { printer: (input: { event: string; parts: string }) => void }): void {
26
+ echo = input.printer;
27
+ }
28
+
17
29
  export function log(event: string, fields: Record<string, unknown> = {}): void {
30
+ let line = "";
18
31
  try {
32
+ line = Object.entries(fields)
33
+ .map(([k, v]) => {
34
+ const str = z.string().safeParse(v);
35
+ return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
36
+ })
37
+ .join(" ");
19
38
  mkdirSync(dirname(paths.logFile), { recursive: true });
20
- const parts = Object.entries(fields).map(([k, v]) => {
21
- const str = z.string().safeParse(v);
22
- return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
23
- });
24
- appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${parts.join(" ")}\n`);
39
+ // Rotation cap: the check timer logs every 180s and the serve daemon
40
+ // echoes every event, so an uncapped append-only file grows forever on a
41
+ // live install (closing-review critic gap). One .old generation bounds
42
+ // total disk at ~2x the cap; older history is disposable diagnostics.
43
+ if (existsSync(paths.logFile) && statSync(paths.logFile).size > LOG_MAX_BYTES) {
44
+ renameSync(paths.logFile, `${paths.logFile}.old`);
45
+ }
46
+ appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${line}\n`);
25
47
  } catch {
26
48
  // logging must never throw into a hook / supervisor path
27
49
  }
50
+ // separate from the file sink: an unwritable log file must not also silence
51
+ // the terminal echo (that is exactly when the daemon needs to stay visible).
52
+ try {
53
+ echo?.({ event, parts: line });
54
+ } catch {
55
+ // the echo printer must never throw into the daemon either
56
+ }
28
57
  }
package/src/lib/oauth.ts CHANGED
@@ -8,12 +8,16 @@
8
8
  // live to answer HTTP 200 with plain Bearer auth (with or without the
9
9
  // oauth beta header - we send it to match the CLI's OAuth convention).
10
10
 
11
- import { http } from "./http.ts";
11
+ import { z } from "zod";
12
+ import { http, safeErrorDetail } from "./http.ts";
12
13
  import { RefreshResponseSchema, RolesResponseSchema, type OAuthCreds, type RolesResponse } from "./types.ts";
13
14
 
14
- const TOKEN_URL = process.env.TOKENMAXXING_OAUTH_TOKEN_URL ?? "https://platform.claude.com/v1/oauth/token";
15
- const CLIENT_ID = process.env.TOKENMAXXING_OAUTH_CLIENT_ID ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
16
- const ROLES_URL = process.env.TOKENMAXXING_OAUTH_ROLES_URL ?? "https://api.anthropic.com/api/oauth/claude_cli/roles";
15
+ // zod-parsed like every other env override (repo rule): a set-but-EMPTY value
16
+ // parses to undefined and the default applies, instead of posting to "".
17
+ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
18
+ const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_TOKEN_URL) ?? "https://platform.claude.com/v1/oauth/token";
19
+ const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_CLIENT_ID) ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
20
+ const ROLES_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_ROLES_URL) ?? "https://api.anthropic.com/api/oauth/claude_cli/roles";
17
21
 
18
22
  const DEFAULT_SCOPES = [
19
23
  "user:profile",
@@ -53,23 +57,26 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
53
57
  body: JSON.stringify(body),
54
58
  });
55
59
  } catch (e) {
56
- throw new Error(`token endpoint unreachable: ${String((e as Error).message ?? e)}`);
60
+ throw new Error(`token endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
57
61
  }
58
62
 
59
63
  const text = await res.text();
60
64
  if (!res.ok) {
61
65
  // invalid_grant → dead refresh token; anything else is transient/unknown.
66
+ // Bodies go through the safeErrorDetail allowlist, never raw: a token
67
+ // endpoint's failure body can echo request material.
62
68
  if (res.status === 400 && /invalid_grant/.test(text)) {
63
- throw new InvalidGrantError(text.slice(0, 200));
69
+ throw new InvalidGrantError(safeErrorDetail({ text }));
64
70
  }
65
- throw new Error(`token refresh failed (HTTP ${res.status}): ${text.slice(0, 200)}`);
71
+ throw new Error(`token refresh failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
66
72
  }
67
73
 
68
74
  const parsed = RefreshResponseSchema.safeParse((() => {
69
75
  try { return JSON.parse(text); } catch { return null; }
70
76
  })());
71
77
  if (!parsed.success) {
72
- throw new Error(`token endpoint returned unexpected body: ${text.slice(0, 120)}`);
78
+ // A success-status body holds live tokens: never echo any of it.
79
+ throw new Error(`token endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
73
80
  }
74
81
  const json = parsed.data;
75
82
 
@@ -105,13 +112,13 @@ export async function fetchTokenOrg(accessToken: string): Promise<RolesResponse>
105
112
  headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
106
113
  });
107
114
  } catch (e) {
108
- throw new Error(`roles endpoint unreachable: ${String((e as Error).message ?? e)}`);
115
+ throw new Error(`roles endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
109
116
  }
110
117
  const text = await res.text();
111
- if (!res.ok) throw new Error(`roles check failed (HTTP ${res.status}): ${text.slice(0, 140)}`);
118
+ if (!res.ok) throw new Error(`roles check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
112
119
  const parsed = RolesResponseSchema.safeParse((() => {
113
120
  try { return JSON.parse(text); } catch { return null; }
114
121
  })());
115
- if (!parsed.success) throw new Error(`roles endpoint returned unexpected body: ${text.slice(0, 120)}`);
122
+ if (!parsed.success) throw new Error(`roles endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
116
123
  return parsed.data;
117
124
  }
package/src/lib/paths.ts CHANGED
@@ -16,7 +16,7 @@ function env(name: string, fallback: string): string {
16
16
  }
17
17
 
18
18
  /** Root of all tokenmaxxing config + state. Default ~/.config/tokenmaxxing. */
19
- export const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
19
+ const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
20
20
 
21
21
  export const paths = {
22
22
  home: TM_HOME,
@@ -25,6 +25,8 @@ export const paths = {
25
25
  usageJson: join(TM_HOME, "usage.json"),
26
26
  modelUsageJson: join(TM_HOME, "model-usage.json"),
27
27
  lastSwapJson: join(TM_HOME, "lastswap.json"),
28
+ /** the last depleted-wait decision, replayed to sibling hooks (self-expiring). */
29
+ depletedJson: join(TM_HOME, "depleted.json"),
28
30
  respawnDir: join(TM_HOME, "respawn"),
29
31
  binDir: join(TM_HOME, "bin"),
30
32
  supervisorLink: join(TM_HOME, "bin", "claude"),
@@ -36,20 +38,23 @@ export const paths = {
36
38
  credsDir: join(TM_HOME, "creds"),
37
39
 
38
40
  /** `xx serve` slack bridge: tokens + channel->repo links (0600: holds the
39
- * xoxb-/xapp- tokens), per-thread claude session records, and the git
40
- * worktrees threads run in. */
41
+ * xoxb-/xapp- tokens), per-thread claude session records, and the
42
+ * single-instance flock (a new daemon generation blocks on it until the
43
+ * previous one - possibly still draining an in-flight turn - fully exits,
44
+ * so two generations never act on the same thread records or cwd). */
41
45
  slackJson: join(TM_HOME, "slack.json"),
42
46
  slackThreadsDir: join(TM_HOME, "slack-threads"),
43
- slackWorktreesDir: join(TM_HOME, "slack-worktrees"),
47
+ serveLockFile: join(TM_HOME, "serve-lock"),
44
48
 
45
49
  /** ~/.claude.json - holds the active `oauthAccount` identity object. */
46
50
  claudeJson: env("TOKENMAXXING_CLAUDE_JSON", join(HOME, ".claude.json")),
47
- /** ~/.claude/settings.json - user-owned; we merge three entries into it. */
51
+ /** ~/.claude/settings.json - user-owned; we merge four entries into it. */
48
52
  claudeSettings: env(
49
53
  "TOKENMAXXING_CLAUDE_SETTINGS",
50
54
  join(env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")), "settings.json"),
51
55
  ),
52
- /** ~/.claude - for the credential-refresh lock and projects/ transcripts. */
56
+ /** ~/.claude - claude's config dir (settings.json, projects/ transcripts;
57
+ * credDir() falls back to it). */
53
58
  claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
54
59
 
55
60
  /** where the periodic-check timer units live (launchd / systemd user). */
@@ -57,11 +62,6 @@ export const paths = {
57
62
  systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
58
63
  } as const;
59
64
 
60
- /** Claude's own credential-refresh lock (verified path filled from facts). */
61
- export function claudeLockPath(): string {
62
- return env("TOKENMAXXING_CLAUDE_LOCK", join(HOME, ".claude.lock"));
63
- }
64
-
65
65
  /** Codex home: where the live auth.json lives. Test override first, then
66
66
  * codex's own CODEX_HOME env, then its default ~/.codex. */
67
67
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
@@ -86,6 +86,12 @@ export const codexPaths = {
86
86
  * running account's parked token must never be refreshed or targeted (its
87
87
  * live rotations supersede the parked copy, and reuse is punished). */
88
88
  presenceDir: join(TM_HOME, "codex-live"),
89
+ /** cross-session reconcile signals, one file per supervisorId: a deciding
90
+ * actor saw that supervisor's session running on a pooled NON-LIVE account
91
+ * (healthy or not - owner decisions 2026-07-20) while the live seat is
92
+ * usable; the session's OWN Stop hook promotes the signal into a respawn
93
+ * marker at its next turn boundary. */
94
+ reconcileDir: join(TM_HOME, "codex-reconcile"),
89
95
  } as const;
90
96
 
91
97
  /** Per-account parked codex credential file name: tokenmaxxing-codex-<id8>. */
package/src/lib/picker.ts CHANGED
@@ -75,7 +75,10 @@ function blockingUntil(a: Account, ctx: PickCtx): number[] {
75
75
  blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
76
76
  ]
77
77
  : []),
78
- ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly)),
78
+ // per-model rows date by their OWN sample time when known: lastUsageAt
79
+ // advances on every engaged evaluation while the rows may be days older,
80
+ // which inflated the null-reset self-bound (closing-review catch).
81
+ ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastPerModelAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
79
82
  ];
80
83
  }
81
84
 
@@ -127,10 +130,15 @@ export function pacePressure(a: Account, now: number): number {
127
130
  /** The switch preference: furthest behind its own weekly pace first (highest
128
131
  * pacePressure), tiebreak soonest weekly expiry then lowest 7-day usage.
129
132
  * Ranks swaps only; display surfaces order by earliestReset instead. */
130
- export const swapPreference = (now: number) => [
133
+ const swapPreference = (now: number) => [
131
134
  (a: Account) => -pacePressure(a, now),
132
135
  (a: Account) => weeklyExpiry(a, now),
133
- (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
136
+ // unmeasured ranks LAST in this tiebreak (101 > any real percentage):
137
+ // `?? 0` made a never-sampled account look maximally safe and beat any
138
+ // measured one, swapping a healthy measured seat onto a complete unknown -
139
+ // the exact unmeasured-must-not-look-safe rule (closing-review catch);
140
+ // pacePressure already ranks unmeasured last by the same principle.
141
+ (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
134
142
  ];
135
143
 
136
144
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
@@ -0,0 +1,37 @@
1
+ // Process identity beyond a bare PID: pids recycle, so anything that must act
2
+ // on "the process I recorded earlier" (reaping an orphan, trusting a presence
3
+ // file) pins pid + start time and treats a mismatch as a different process.
4
+
5
+ import { z } from "zod";
6
+
7
+ /** The ps lstart token for a pid, or null when no such process. pid + start
8
+ * time is the standard process identity: equality with the token captured
9
+ * at spawn proves this is still the SAME process, never a recycled pid.
10
+ * LC_ALL=C pins the lstart rendering (cubic review catch): the capturing and
11
+ * the comparing process can run under different locales (terminal vs
12
+ * launchd), and a formatting mismatch would silently break the identity.
13
+ * Tradeoff (flagged and accepted): lstart has one-second resolution, so a
14
+ * pid recycled onto a process started within the SAME wall-clock second
15
+ * would pass - landing on the exact pid AND second is vanishingly unlikely,
16
+ * and finer start-time sources are per-platform native calls ps cannot give. */
17
+ export function pidStartTime(pid: number): string | null {
18
+ const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
19
+ if (res.exitCode !== 0) return null;
20
+ const lstart = res.stdout.toString().trim();
21
+ return lstart === "" ? null : lstart;
22
+ }
23
+
24
+ /** Whether the pid names a LIVE process (signal-0 probe); EPERM still proves
25
+ * existence. Distinguishes "pid is dead" from "ps could not answer", which a
26
+ * null pidStartTime alone cannot. */
27
+ export function pidExists(pid: number): boolean {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch (e) {
32
+ const errno = z.object({ code: z.string() }).safeParse(e);
33
+ if (errno.success && errno.data.code === "ESRCH") return false;
34
+ if (errno.success && errno.data.code === "EPERM") return true;
35
+ throw e;
36
+ }
37
+ }
package/src/lib/sample.ts CHANGED
@@ -31,22 +31,32 @@ import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse
31
31
  /** Result of a live sample: the fresh usage, or why it could not be taken.
32
32
  * `pingError` is set only when a requested ping (status --force) failed - the
33
33
  * account's 5h timer may not have started even if the sample itself succeeded. */
34
- export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
34
+ const SampleOutcomeSchema = z.discriminatedUnion("ok", [
35
35
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
36
36
  z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
37
37
  ]);
38
38
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
39
39
 
40
- /** Verify `creds` belongs to `account`; null on match, else the mismatch reason. */
41
- async function identityMismatch(creds: OAuthCreds, account: Account): Promise<string | null> {
40
+ const IdentityCheckSchema = z.discriminatedUnion("status", [
41
+ z.object({ status: z.literal("match") }),
42
+ z.object({ status: z.literal("mismatch"), reason: z.string() }),
43
+ z.object({ status: z.literal("unavailable"), reason: z.string() }),
44
+ ]);
45
+ type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
46
+
47
+ /** Verify `creds` belongs to `account`. Only a definitive org DISAGREEMENT is
48
+ * a mismatch; an unreachable roles endpoint is "unavailable" and must never
49
+ * bench the account - a transient outage is not a dead credential, and
50
+ * flagging on it once removed every parked account from switching. */
51
+ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
42
52
  let org: RolesResponse;
43
53
  try {
44
54
  org = await fetchTokenOrg(creds.accessToken);
45
55
  } catch (e) {
46
- return `credential identity check failed: ${String((e as Error).message ?? e)}`;
56
+ return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
47
57
  }
48
- if (org.organization_uuid === account.organizationUuid) return null;
49
- return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})`;
58
+ if (org.organization_uuid === account.organizationUuid) return { status: "match" };
59
+ return { status: "mismatch", reason: `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})` };
50
60
  }
51
61
 
52
62
  /** Stamp the blob's plan fields onto the account (caller persists). Runs only
@@ -74,11 +84,36 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
74
84
  try {
75
85
  creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
76
86
  } catch (e) {
77
- return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
87
+ return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
88
+ }
89
+
90
+ // The parked copy must never be refreshed (or probed - the probe can rotate
91
+ // it too) while its account secretly owns the LIVE login: after a crash
92
+ // between performSwap's live install and the oauthAccount rewrite, status
93
+ // still routes the live account here, and a parked-side rotation would
94
+ // supersede the live item's single-use refresh token out from under the
95
+ // running session - or, if claude rotated first, falsely flag the healthy
96
+ // live account needsReauth (closing-review catch; mirrors the codex
97
+ // sampler's present-account invariant). Verified against the live blob's
98
+ // TRUE org, fail-closed like the rm guard: an unverifiable live owner
99
+ // refuses the sample rather than risking the live grant.
100
+ const liveRaw = await readItem(liveTarget());
101
+ if (liveRaw != null) {
102
+ let liveOrg: string;
103
+ try {
104
+ const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
105
+ liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
106
+ } catch (e) {
107
+ return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
108
+ }
109
+ if (liveOrg === account.organizationUuid) {
110
+ return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
111
+ }
78
112
  }
79
113
 
80
114
  // Hand claude a token with comfortable headroom so it won't run its own refresh
81
- // (which claude does within 120s of expiry). Refresh + persist ourselves first.
115
+ // (which claude does within 300s of expiry - the same margin checked here).
116
+ // Refresh + persist ourselves first.
82
117
  if (isAccessTokenExpiring(creds, 300_000)) {
83
118
  try {
84
119
  creds = await refreshCredential(creds);
@@ -86,16 +121,19 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
86
121
  } catch (e) {
87
122
  if (e instanceof InvalidGrantError) {
88
123
  account.needsReauth = true;
89
- return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing add`" };
124
+ return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
90
125
  }
91
- return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
126
+ return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
92
127
  }
93
128
  }
94
129
 
95
- const mismatch = await identityMismatch(creds, account);
96
- if (mismatch) {
130
+ const identity = await checkIdentity(creds, account);
131
+ if (identity.status === "mismatch") {
97
132
  account.needsReauth = true;
98
- return { ok: false, reason: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
133
+ return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
134
+ }
135
+ if (identity.status === "unavailable") {
136
+ return { ok: false, reason: identity.reason };
99
137
  }
100
138
  refreshPlanFields(account, creds);
101
139
 
@@ -124,6 +162,35 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
124
162
  }
125
163
  }
126
164
 
165
+ /** Refresh the LIVE access token when near expiry, under claude's own refresh
166
+ * lock. Exported for `xx status`: every parked probe's fail-closed live-owner
167
+ * check reads this token, so it must be fresh BEFORE those probes run - even
168
+ * when the active account's own usage comes from the statusline tee and no
169
+ * active probe happens (cubic review catch, PR #35: the tee short-circuit
170
+ * skipped the refresh and every parked sample 401'd on the first post-idle
171
+ * status). No live item, or an unparsable one, is a no-op here: the callers'
172
+ * own guards surface those loudly. InvalidGrantError propagates. */
173
+ export async function ensureLiveTokenFresh(): Promise<void> {
174
+ const liveRaw = await readItem(liveTarget());
175
+ if (!liveRaw) return;
176
+ let creds: OAuthCreds;
177
+ try {
178
+ creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
179
+ } catch {
180
+ return;
181
+ }
182
+ if (!isAccessTokenExpiring(creds, 300_000)) return;
183
+ await withClaudeRefreshLock(async (lock) => {
184
+ const raw2 = await readItem(liveTarget());
185
+ if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
186
+ const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
187
+ const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
188
+ if (next === current) return;
189
+ if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
190
+ await writeItem(liveTarget(), mergeIntoLive(raw2, next));
191
+ });
192
+ }
193
+
127
194
  /**
128
195
  * Live-sample the ACTIVE account off the live login, verifying the live
129
196
  * credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
@@ -132,32 +199,25 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
132
199
  * the identity check - never spend quota on a drifted credential).
133
200
  */
134
201
  export async function probeActiveUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
202
+ // A running claude keeps the live token fresh; after long idle it may not have.
203
+ try {
204
+ await ensureLiveTokenFresh();
205
+ } catch (e) {
206
+ if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
207
+ return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
208
+ }
135
209
  const liveRaw = await readItem(liveTarget());
136
210
  if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
137
211
  let creds: OAuthCreds;
138
212
  try {
139
213
  creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
140
214
  } catch (e) {
141
- return { ok: false, reason: `live credential blob unreadable (${String((e as Error).message ?? e).slice(0, 80)})` };
142
- }
143
-
144
- // A running claude keeps the live token fresh; after long idle it may not have.
145
- if (isAccessTokenExpiring(creds, 300_000)) {
146
- try {
147
- await withClaudeRefreshLock(async () => {
148
- const raw2 = (await readItem(liveTarget())) ?? liveRaw;
149
- const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
150
- creds = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
151
- if (creds !== current) await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
152
- });
153
- } catch (e) {
154
- if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
155
- return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
156
- }
215
+ return { ok: false, reason: `live credential blob unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})` };
157
216
  }
158
217
 
159
- const mismatch = await identityMismatch(creds, account);
160
- if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
218
+ const identity = await checkIdentity(creds, account);
219
+ if (identity.status === "mismatch") return { ok: false, reason: `live ${identity.reason} - active label drifted; run \`tokenmaxxing switch\`` };
220
+ if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
161
221
  refreshPlanFields(account, creds);
162
222
 
163
223
  const pingError = opts.ping ? await pingSession() : null;
@@ -3,7 +3,7 @@
3
3
  // recovery in #20) re-applies them instead of dropping --dangerously-skip-
4
4
  // permissions / --model / etc.
5
5
 
6
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { z } from "zod";
9
9
  import { paths } from "./paths.ts";
@@ -11,6 +11,12 @@ import { writeFileAtomic } from "./atomic.ts";
11
11
 
12
12
  const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
13
13
 
14
+ // Matches claude's default transcript retention (cleanupPeriodDays 30): a
15
+ // transcript claude has already deleted cannot be resumed, so its flags file
16
+ // is dead weight. saveSessionFlags rewrites the file on every (re)launch, so
17
+ // an actively resumed session keeps its mtime fresh and is never pruned.
18
+ const SESSION_RETENTION_MS = 30 * 24 * 3600 * 1000;
19
+
14
20
  function sessionFile(sid: string): string {
15
21
  return join(paths.home, "sessions", `${sid}.json`);
16
22
  }
@@ -25,3 +31,19 @@ export function loadSessionFlags(sid: string): string[] | null {
25
31
  if (!existsSync(f)) return null;
26
32
  return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
27
33
  }
34
+
35
+ /** Delete session files past the retention window (also reaps stale
36
+ * writeFileAtomic temp siblings from a crashed writer). */
37
+ export function pruneStaleSessions(now: number): void {
38
+ const dir = join(paths.home, "sessions");
39
+ if (!existsSync(dir)) return;
40
+ for (const f of readdirSync(dir)) {
41
+ const p = join(dir, f);
42
+ try {
43
+ if (now - statSync(p).mtimeMs > SESSION_RETENTION_MS) rmSync(p, { force: true });
44
+ } catch {
45
+ // A concurrent writeFileAtomic renames its tmp sibling away between
46
+ // readdir and stat; a vanished entry needs no pruning.
47
+ }
48
+ }
49
+ }