tokenmaxxing 0.19.1 → 1.0.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 (61) hide show
  1. package/DESIGN.md +34 -25
  2. package/README.md +6 -5
  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/codexrm.ts +49 -0
  10. package/src/cli/codexswitch.ts +20 -2
  11. package/src/cli/config.ts +25 -1
  12. package/src/cli/doctor.ts +3 -3
  13. package/src/cli/init.ts +54 -32
  14. package/src/cli/onboard.ts +62 -45
  15. package/src/cli/rename.ts +20 -0
  16. package/src/cli/render.ts +0 -16
  17. package/src/cli/rm.ts +40 -2
  18. package/src/cli/serve.ts +638 -115
  19. package/src/cli/status.ts +69 -23
  20. package/src/cli/switch.ts +54 -19
  21. package/src/entries/codexstophook.ts +123 -4
  22. package/src/entries/codexsupervisor.ts +87 -13
  23. package/src/entries/sessionstart.ts +1 -1
  24. package/src/entries/statusline.ts +56 -20
  25. package/src/entries/stophook.ts +23 -9
  26. package/src/entries/supervisor.ts +184 -20
  27. package/src/lib/atomic.ts +28 -6
  28. package/src/lib/claudebin.ts +2 -2
  29. package/src/lib/claudejson.ts +5 -5
  30. package/src/lib/claudelock.ts +112 -37
  31. package/src/lib/codexauth.ts +18 -3
  32. package/src/lib/codexbin.ts +1 -1
  33. package/src/lib/codexdecide.ts +149 -19
  34. package/src/lib/codexpick.ts +17 -6
  35. package/src/lib/codexpresence.ts +59 -21
  36. package/src/lib/codexsample.ts +17 -8
  37. package/src/lib/codexswap.ts +10 -1
  38. package/src/lib/credstore.ts +6 -2
  39. package/src/lib/decide.ts +136 -49
  40. package/src/lib/install.ts +125 -17
  41. package/src/lib/keychain.ts +41 -15
  42. package/src/lib/lock.ts +57 -35
  43. package/src/lib/log.ts +36 -7
  44. package/src/lib/oauth.ts +18 -11
  45. package/src/lib/paths.ts +17 -11
  46. package/src/lib/picker.ts +11 -3
  47. package/src/lib/proc.ts +37 -0
  48. package/src/lib/sample.ts +91 -31
  49. package/src/lib/sessions.ts +23 -1
  50. package/src/lib/settings.ts +59 -18
  51. package/src/lib/slackbridge.ts +581 -81
  52. package/src/lib/slackstate.ts +159 -12
  53. package/src/lib/slackstream.ts +123 -20
  54. package/src/lib/state.ts +131 -35
  55. package/src/lib/swap.ts +109 -47
  56. package/src/lib/types.ts +92 -38
  57. package/src/lib/usage.ts +114 -16
  58. package/src/main.ts +70 -9
  59. package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
  60. package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
  61. package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
@@ -11,8 +11,15 @@ const KeychainTargetSchema = z.object({ service: z.string(), account: z.string()
11
11
  export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
12
12
 
13
13
  const SECURITY = "/usr/bin/security";
14
- // security(1) interactive mode has a ~4KB line buffer; above this we must use argv.
15
- const INTERACTIVE_MAX = 3800;
14
+ // security(1) interactive mode has a 4096-byte line buffer. The gate below
15
+ // measures the ASSEMBLED line against this (with margin), never the raw
16
+ // secret: quoteDouble expansion (one byte per `"`/`\` when the blob contains
17
+ // an apostrophe) once pushed a raw-length-passing line over the buffer, which
18
+ // SPLITS the line - the write exits 1 ("unknown command" for the spilled
19
+ // remainder) AND the item is left holding a TRUNCATED secret (empirically
20
+ // verified 2026-07-20: a 3667-byte secret assembling to a 5574-byte line
21
+ // corrupted the item to its first 2682 bytes before the error surfaced).
22
+ const INTERACTIVE_MAX_LINE = 4000;
16
23
 
17
24
  /** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
18
25
  function quoteDouble(s: string): string {
@@ -24,26 +31,40 @@ function quoteValue(s: string): string {
24
31
  return s.includes("'") ? quoteDouble(s) : `'${s}'`;
25
32
  }
26
33
 
27
- /** Read an item's password blob. Returns null if the item does not exist. */
34
+ /** Read an item's password blob. Returns null ONLY when the item verifiably
35
+ * does not exist (exit 44, errSecItemNotFound - empirically pinned on this
36
+ * Mac 2026-07-20). Every other failure THROWS: a locked keychain or denied
37
+ * ACL prompt reading as "absent" silently disarmed every fail-closed
38
+ * live-owner guard and the mandatory pre-swap harvest, all of which key off
39
+ * a null read (closing-review catch). stderr never carries the secret. */
28
40
  export async function readItem(t: KeychainTarget): Promise<string | null> {
29
41
  const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
30
42
  stdout: "pipe",
31
- stderr: "ignore",
43
+ stderr: "pipe",
32
44
  });
33
- const out = await new Response(p.stdout).text();
45
+ const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
34
46
  await p.exited;
35
- if (p.exitCode !== 0) return null; // 44 = not found
47
+ if (p.exitCode === 44) return null;
48
+ if (p.exitCode !== 0) {
49
+ throw new Error(`keychain read failed (exit ${p.exitCode}): ${err.trim().slice(0, 200)} - a locked keychain or denied ACL must fail loudly, never read as absent`);
50
+ }
36
51
  return out.replace(/\n$/, ""); // security appends exactly one trailing newline
37
52
  }
38
53
 
39
- /** ps-safe write: the command line + secret arrive on stdin, never in argv.
40
- * Bounded by security(1)'s ~4KB interactive line buffer. */
41
- async function writeViaInteractive(t: KeychainTarget, secret: string): Promise<void> {
42
- const line =
54
+ /** The full `security -i` line for a write; its LENGTH is the argv-fallback
55
+ * gate, so it is assembled once, here. */
56
+ function interactiveLine(t: KeychainTarget, secret: string): string {
57
+ return (
43
58
  `add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
44
- `-w ${quoteValue(secret)}\n`;
59
+ `-w ${quoteValue(secret)}\n`
60
+ );
61
+ }
62
+
63
+ /** ps-safe write: the command line + secret arrive on stdin, never in argv.
64
+ * The caller guarantees the encoded line fits the interactive buffer. */
65
+ async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
45
66
  const p = Bun.spawn([SECURITY, "-i"], {
46
- stdin: new TextEncoder().encode(line),
67
+ stdin: encodedLine,
47
68
  stdout: "ignore",
48
69
  stderr: "pipe",
49
70
  });
@@ -66,10 +87,15 @@ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
66
87
  }
67
88
 
68
89
  /** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
69
- * ps-safe stdin path; falls back to argv only when the blob exceeds the ~4KB
70
- * interactive line limit. Throws on failure. */
90
+ * ps-safe stdin path; falls back to argv when the ASSEMBLED interactive line
91
+ * would exceed the buffer (see INTERACTIVE_MAX_LINE - gating on the raw
92
+ * secret length let quote expansion corrupt the item). Throws on failure. */
71
93
  export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
72
- if (secret.length <= INTERACTIVE_MAX) return writeViaInteractive(t, secret);
94
+ // measured in UTF-8 BYTES, the unit the stdin buffer actually consumes:
95
+ // String.length counts UTF-16 code units and under-counts multibyte
96
+ // characters (cubic review catch, PR #35).
97
+ const encoded = new TextEncoder().encode(interactiveLine(t, secret));
98
+ if (encoded.length <= INTERACTIVE_MAX_LINE) return writeViaInteractive(encoded);
73
99
  return writeViaArgv(t, secret);
74
100
  }
75
101
 
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
+ }