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.
- package/DESIGN.md +34 -23
- package/README.md +4 -4
- 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/codexswitch.ts +15 -1
- package/src/cli/config.ts +10 -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/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +650 -78
- 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 +134 -18
- 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 +10 -2
- 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 +114 -42
- 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 +583 -76
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +127 -21
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +79 -37
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +61 -7
- 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/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
|
+
}
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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: ${
|
|
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
|
|
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 (${
|
|
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
|
|
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
|
|
124
|
+
return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
|
|
90
125
|
}
|
|
91
|
-
return { ok: false, reason: `token refresh failed: ${
|
|
126
|
+
return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
|
|
92
127
|
}
|
|
93
128
|
}
|
|
94
129
|
|
|
95
|
-
const
|
|
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: `${
|
|
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 (${
|
|
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
|
|
160
|
-
if (mismatch) return { ok: false, reason: `live ${
|
|
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;
|
package/src/lib/sessions.ts
CHANGED
|
@@ -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
|
+
}
|