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.
- package/DESIGN.md +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/keychain.ts
CHANGED
|
@@ -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
|
|
15
|
-
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
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
|
|
70
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
29
|
+
const FLOCK_DEF = { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } } as const;
|
|
15
30
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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`,
|
|
45
|
-
* Returns a handle whose release() drops the
|
|
46
|
-
*
|
|
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 =
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 {
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
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: ${
|
|
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(
|
|
69
|
+
throw new InvalidGrantError(safeErrorDetail({ text }));
|
|
64
70
|
}
|
|
65
|
-
throw new Error(`token refresh failed (HTTP ${res.status}): ${
|
|
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
|
-
|
|
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: ${
|
|
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}): ${
|
|
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
|
|
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
|
-
|
|
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
|
|
40
|
-
*
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
133
|
+
const swapPreference = (now: number) => [
|
|
131
134
|
(a: Account) => -pacePressure(a, now),
|
|
132
135
|
(a: Account) => weeklyExpiry(a, now),
|
|
133
|
-
(
|
|
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 {
|
package/src/lib/proc.ts
ADDED
|
@@ -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
|
+
}
|