tokenmaxxing 1.7.0 → 1.9.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 +2 -2
- package/README.md +1 -1
- package/agent-plugin/plugin.json +8 -2
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +11 -42
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +8 -34
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -63
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -23
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +57 -124
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -45
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -35
- package/src/sdk.ts +0 -59
package/src/lib/settings.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
// Idempotent merge of tokenmaxxing's entries into the user-owned
|
|
2
|
-
// ~/.claude/settings.json: our statusLine + subagentStatusLine, a Stop hook,
|
|
3
|
-
// a SessionStart hook. Hooks APPEND to existing arrays; the statusLine slots
|
|
4
|
-
// are ours outright - tokenmaxxing renders them natively, so any other
|
|
5
|
-
// statusLine command is replaced.
|
|
6
|
-
|
|
7
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
2
|
import { join } from "node:path";
|
|
9
3
|
import { z } from "zod";
|
|
10
4
|
import { paths } from "./paths.ts";
|
|
11
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
12
6
|
|
|
13
|
-
/** Absolute path to the installed tokenmaxxing binary the settings entries call. */
|
|
14
7
|
export function installedBin(): string {
|
|
15
8
|
return join(paths.binDir, "tokenmaxxing");
|
|
16
9
|
}
|
|
@@ -30,62 +23,50 @@ const SUBCMD = {
|
|
|
30
23
|
statusline: "__statusline",
|
|
31
24
|
subagentStatusline: "__subagent-statusline",
|
|
32
25
|
stop: "__stop-hook",
|
|
26
|
+
stopFailure: "__stop-failure-hook",
|
|
33
27
|
sessionStart: "__session-start",
|
|
34
28
|
} as const;
|
|
35
29
|
|
|
30
|
+
const STOP_FAILURE_MATCHER = "rate_limit";
|
|
31
|
+
|
|
36
32
|
function readSettings(): Settings {
|
|
37
33
|
if (!existsSync(paths.claudeSettings)) return {};
|
|
38
34
|
return SettingsSchema.parse(JSON.parse(readFileSync(paths.claudeSettings, "utf8")));
|
|
39
35
|
}
|
|
40
36
|
|
|
41
37
|
function writeSettings(s: Settings): void {
|
|
42
|
-
// Preserve the user's mode: settings.json can carry an env block with
|
|
43
|
-
// credentials, and the atomic rename would otherwise widen a 0600 file to
|
|
44
|
-
// world-readable. A brand-new file starts at the conservative 0600.
|
|
45
38
|
const mode = existsSync(paths.claudeSettings) ? statSync(paths.claudeSettings).mode & 0o777 : 0o600;
|
|
46
39
|
writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", mode);
|
|
47
40
|
}
|
|
48
41
|
|
|
49
|
-
/** True if a hook/statusline command string is one tokenmaxxing installed. */
|
|
50
42
|
function isOurCommand(cmd: string | undefined): boolean {
|
|
51
43
|
if (!cmd) return false;
|
|
52
44
|
return (
|
|
53
45
|
cmd.includes(SUBCMD.statusline) ||
|
|
54
46
|
cmd.includes(SUBCMD.subagentStatusline) ||
|
|
55
47
|
cmd.includes(SUBCMD.stop) ||
|
|
48
|
+
cmd.includes(SUBCMD.stopFailure) ||
|
|
56
49
|
cmd.includes(SUBCMD.sessionStart) ||
|
|
57
|
-
// also match the installed bin path even if the subcommand text changes
|
|
58
50
|
cmd.includes(installedBin())
|
|
59
51
|
);
|
|
60
52
|
}
|
|
61
53
|
|
|
62
|
-
/** The exact command string installSettings writes for a subcommand; doctor
|
|
63
|
-
* compares against it verbatim, so a green check proves the canonical entry. */
|
|
64
54
|
function ourCommand(sub: string): string {
|
|
65
55
|
return `${JSON.stringify(installedBin())} ${sub}`;
|
|
66
56
|
}
|
|
67
57
|
|
|
68
|
-
function ourHookGroup(sub: string): HookGroup {
|
|
69
|
-
return { hooks: [{ type: "command", command: ourCommand(sub) }] };
|
|
58
|
+
function ourHookGroup(sub: string, matcher?: string): HookGroup {
|
|
59
|
+
return { ...(matcher ? { matcher } : {}), hooks: [{ type: "command", command: ourCommand(sub) }] };
|
|
70
60
|
}
|
|
71
61
|
|
|
72
|
-
function appendHook(s: Settings, event: string, sub: string): void {
|
|
62
|
+
function appendHook(s: Settings, event: string, sub: string, matcher?: string): void {
|
|
73
63
|
s.hooks ??= {};
|
|
74
64
|
s.hooks[event] ??= [];
|
|
75
65
|
const arr = s.hooks[event]!;
|
|
76
66
|
const present = arr.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
|
|
77
|
-
if (!present) arr.push(ourHookGroup(sub));
|
|
67
|
+
if (!present) arr.push(ourHookGroup(sub, matcher));
|
|
78
68
|
}
|
|
79
69
|
|
|
80
|
-
/** True only for a command tokenmaxxing itself wrote - the exact historical
|
|
81
|
-
* shape `"<...>/tokenmaxxing" <sub>` at ANY install path (so stale
|
|
82
|
-
* pre-relocation entries match too). A foreign command that merely mentions
|
|
83
|
-
* the subcommand or the path as text is NOT ours and must survive removal. */
|
|
84
|
-
/** Structural ownership: exactly `"<path>/tokenmaxxing" <sub>`. Exported for
|
|
85
|
-
* the codex hooks.json installer, whose old includes()-based match deleted
|
|
86
|
-
* foreign hooks sharing a group and misclassified commands merely mentioning
|
|
87
|
-
* the subcommand (closing-review catch; the same class settings.ts's own
|
|
88
|
-
* removeHook was fixed for in PR #31). */
|
|
89
70
|
export function isOurHookCommand(cmd: string, sub: string): boolean {
|
|
90
71
|
if (!cmd.endsWith(` ${sub}`)) return false;
|
|
91
72
|
const quotedPath = cmd.slice(0, cmd.length - (sub.length + 1));
|
|
@@ -103,34 +84,28 @@ export function isOurHookCommand(cmd: string, sub: string): boolean {
|
|
|
103
84
|
function removeHook(s: Settings, event: string, sub: string): void {
|
|
104
85
|
const arr = s.hooks?.[event];
|
|
105
86
|
if (!arr) return;
|
|
106
|
-
// Strip only VERIFIED tokenmaxxing-owned entries from WITHIN each group:
|
|
107
|
-
// foreign hooks sharing a group - or merely mentioning our strings - survive
|
|
108
|
-
// (review catches, PR #31), and a group is dropped only once it is empty.
|
|
109
87
|
for (const g of arr) g.hooks = g.hooks.filter((h) => !isOurHookCommand(h.command, sub));
|
|
110
88
|
s.hooks![event] = arr.filter((g) => g.hooks.length > 0);
|
|
111
89
|
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
112
90
|
}
|
|
113
91
|
|
|
114
|
-
/** Install the entries: take both statusLine slots, append our hooks. Stale
|
|
115
|
-
* same-subcommand hooks from an OLD install path are dropped first, so a
|
|
116
|
-
* TOKENMAXXING_HOME relocation rewrites the entries instead of leaving dead
|
|
117
|
-
* paths that read as installed (relocation residue is how the supervisor
|
|
118
|
-
* recursion incident started). Foreign hooks are untouched. */
|
|
119
92
|
export function installSettings(): void {
|
|
120
93
|
const s = readSettings();
|
|
121
94
|
s.statusLine = { type: "command", command: ourCommand(SUBCMD.statusline) };
|
|
122
95
|
s.subagentStatusLine = { type: "command", command: ourCommand(SUBCMD.subagentStatusline) };
|
|
123
96
|
removeHook(s, "Stop", SUBCMD.stop);
|
|
97
|
+
removeHook(s, "StopFailure", SUBCMD.stopFailure);
|
|
124
98
|
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
125
99
|
appendHook(s, "Stop", SUBCMD.stop);
|
|
100
|
+
appendHook(s, "StopFailure", SUBCMD.stopFailure, STOP_FAILURE_MATCHER);
|
|
126
101
|
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
127
102
|
writeSettings(s);
|
|
128
103
|
}
|
|
129
104
|
|
|
130
|
-
/** Remove our entries. The statusLine slots are deleted only if they are ours. */
|
|
131
105
|
export function uninstallSettings(): void {
|
|
132
106
|
const s = readSettings();
|
|
133
107
|
removeHook(s, "Stop", SUBCMD.stop);
|
|
108
|
+
removeHook(s, "StopFailure", SUBCMD.stopFailure);
|
|
134
109
|
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
135
110
|
if (s.statusLine && isOurCommand(s.statusLine.command)) delete s.statusLine;
|
|
136
111
|
if (s.subagentStatusLine && isOurCommand(s.subagentStatusLine.command)) delete s.subagentStatusLine;
|
|
@@ -141,22 +116,20 @@ const SettingsCheckSchema = z.object({
|
|
|
141
116
|
statusLineOk: z.boolean(),
|
|
142
117
|
subagentStatusLineOk: z.boolean(),
|
|
143
118
|
stopOk: z.boolean(),
|
|
119
|
+
stopFailureOk: z.boolean(),
|
|
144
120
|
sessionStartOk: z.boolean(),
|
|
145
121
|
});
|
|
146
122
|
export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
|
|
147
123
|
|
|
148
124
|
export function checkSettings(): SettingsCheck {
|
|
149
125
|
const s = readSettings();
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// install path reads as broken, and a foreign command that merely mentions
|
|
153
|
-
// the path or subcommand as text never green-lights.
|
|
154
|
-
const has = (event: string, sub: string) =>
|
|
155
|
-
!!s.hooks?.[event]?.some((g) => g.hooks.some((h) => h.command === ourCommand(sub)));
|
|
126
|
+
const has = (event: string, sub: string, matcher?: string) =>
|
|
127
|
+
!!s.hooks?.[event]?.some((g) => (matcher === undefined || g.matcher === matcher) && g.hooks.some((h) => h.command === ourCommand(sub)));
|
|
156
128
|
return {
|
|
157
129
|
statusLineOk: s.statusLine?.command === ourCommand(SUBCMD.statusline),
|
|
158
130
|
subagentStatusLineOk: s.subagentStatusLine?.command === ourCommand(SUBCMD.subagentStatusline),
|
|
159
131
|
stopOk: has("Stop", SUBCMD.stop),
|
|
132
|
+
stopFailureOk: has("StopFailure", SUBCMD.stopFailure, STOP_FAILURE_MATCHER),
|
|
160
133
|
sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
|
|
161
134
|
};
|
|
162
135
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// Config + accounts index + usage snapshot persistence. All writes atomic.
|
|
2
|
-
|
|
3
1
|
import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
4
2
|
import { isEqual } from "es-toolkit";
|
|
5
3
|
import { z } from "zod";
|
|
@@ -10,6 +8,7 @@ import {
|
|
|
10
8
|
ConfigSchema,
|
|
11
9
|
LastSwapSchema,
|
|
12
10
|
ModelUsageStateSchema,
|
|
11
|
+
NextCheckSchema,
|
|
13
12
|
UsageStateSchema,
|
|
14
13
|
type AccountsIndex,
|
|
15
14
|
type Config,
|
|
@@ -17,33 +16,16 @@ import {
|
|
|
17
16
|
type UsageState,
|
|
18
17
|
} from "./types.ts";
|
|
19
18
|
|
|
20
|
-
// ---- config.json (minimal, fixed schema) ---------------------------------
|
|
21
|
-
|
|
22
19
|
const DEFAULT_CONFIG: Config = {
|
|
23
|
-
// LAYER 1 - screening bars, split per window (user 2026-07-16): a session
|
|
24
|
-
// reset is cheap to sit out, weekly quota is use-it-or-lose-it so it drains
|
|
25
|
-
// to 98. These drive normal account-to-account switching with headroom.
|
|
26
20
|
thresholds: { session: 95, weekly: 98 },
|
|
27
|
-
// LAYER 2 - the wall bars (default the server's own 100% limit). Reached only
|
|
28
|
-
// once every account is over its Layer 1 bar: from there a session pumps the
|
|
29
|
-
// last drops up to the wall instead of parking with quota unspent.
|
|
30
21
|
hardThresholds: { session: 100, weekly: 100 },
|
|
31
22
|
claudeBin: "",
|
|
32
23
|
codexBin: "",
|
|
33
|
-
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
34
|
-
// per the user 2026-07-12), and only Fable's is worth switching on.
|
|
35
|
-
// greedySessionFloor 50: half a session window buys the swap (user 2026-07-16).
|
|
36
24
|
policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
37
25
|
};
|
|
38
26
|
|
|
39
|
-
/** Percent-of-window values: out-of-range bars make every account read as
|
|
40
|
-
* exhausted, so the schema rejects them at the config gate. The cross-field
|
|
41
|
-
* case (a projectionMargin at or above a threshold zeroes the effective bar)
|
|
42
|
-
* is caught by ConfigSchema's refine on the merged result. */
|
|
43
27
|
const PercentSchema = z.number().min(0).max(100);
|
|
44
28
|
|
|
45
|
-
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
46
|
-
* Exported for `xx config`, which edits and housekeeps the sparse file. */
|
|
47
29
|
export const ConfigFileSchema = z
|
|
48
30
|
.object({
|
|
49
31
|
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
@@ -68,12 +50,6 @@ const MergeOutcomeSchema = z.union([
|
|
|
68
50
|
]);
|
|
69
51
|
export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
|
|
70
52
|
|
|
71
|
-
/** Merge a validated sparse file over the defaults, apply the env binary
|
|
72
|
-
* overrides, and validate the merged WHOLE (the projectionMargin-vs-
|
|
73
|
-
* thresholds refine). Shared by loadConfig and `xx config set`: set must
|
|
74
|
-
* reject a value whose merged result would make every later loadConfig
|
|
75
|
-
* throw, silently disabling status/switch/hooks/statusline until the file is
|
|
76
|
-
* hand-repaired (closing-review catch). */
|
|
77
53
|
export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
|
|
78
54
|
const cfg: Config = {
|
|
79
55
|
...DEFAULT_CONFIG,
|
|
@@ -94,15 +70,12 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
94
70
|
if (p.policy?.switchModels) {
|
|
95
71
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
96
72
|
}
|
|
97
|
-
// env overrides win for the real binaries (tests / relocation)
|
|
98
73
|
const envBin = realClaudeBinFromEnv();
|
|
99
74
|
if (envBin) cfg.claudeBin = envBin;
|
|
100
75
|
const envCodexBin = realCodexBinFromEnv();
|
|
101
76
|
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
102
77
|
const merged = ConfigSchema.safeParse(cfg);
|
|
103
78
|
if (!merged.success) {
|
|
104
|
-
// per-field values passed but the merged whole is unusable (the
|
|
105
|
-
// projectionMargin-vs-thresholds refine); name the reason, not a zod dump.
|
|
106
79
|
return { ok: false, detail: merged.error.issues.map((issue) => issue.message).join("; ") };
|
|
107
80
|
}
|
|
108
81
|
return { ok: true, config: merged.data };
|
|
@@ -115,15 +88,10 @@ export function loadConfig(): Config {
|
|
|
115
88
|
try {
|
|
116
89
|
raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
|
|
117
90
|
} catch {
|
|
118
|
-
// Silent defaults here once meant a corrupt file could quietly unpin
|
|
119
|
-
// claudeBin; a damaged config is the user's to repair, loudly.
|
|
120
91
|
throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
|
|
121
92
|
}
|
|
122
93
|
const parsed = ConfigFileSchema.safeParse(raw);
|
|
123
94
|
if (!parsed.success) {
|
|
124
|
-
// Valid JSON with wrong-typed KNOWN keys must not silently drop pins
|
|
125
|
-
// like claudeBin; unknown keys are stripped by the schema and stay
|
|
126
|
-
// tolerated (that is `config tidy`'s territory, not an error).
|
|
127
95
|
const fields = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
|
|
128
96
|
throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
|
|
129
97
|
}
|
|
@@ -134,12 +102,6 @@ export function loadConfig(): Config {
|
|
|
134
102
|
return outcome.config;
|
|
135
103
|
}
|
|
136
104
|
|
|
137
|
-
/** Pin one binary path into the SPARSE config file, preserving every other
|
|
138
|
-
* override verbatim. Never write the merged config here: baking defaults (or
|
|
139
|
-
* an ambient TOKENMAXXING_*_BIN env override) into the file freezes future
|
|
140
|
-
* default changes as stale explicit values and misreports every `xx config`
|
|
141
|
-
* source as "file" (closing-review catch; the sparse-overrides contract is
|
|
142
|
-
* config.ts's header). Throws on a corrupt file, like loadConfig. */
|
|
143
105
|
export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
|
|
144
106
|
let raw: Record<string, unknown> = {};
|
|
145
107
|
if (existsSync(paths.configJson)) {
|
|
@@ -149,15 +111,9 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: stri
|
|
|
149
111
|
writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
|
|
150
112
|
}
|
|
151
113
|
|
|
152
|
-
// ---- accounts.json -------------------------------------------------------
|
|
153
|
-
|
|
154
114
|
const emptyIndex = (): AccountsIndex => ({ version: 1, activeAccountUuid: null, accounts: [] });
|
|
155
115
|
|
|
156
116
|
export function loadAccounts(): AccountsIndex {
|
|
157
|
-
// Absent = genuinely empty. Present-but-unreadable THROWS (mirrors the codex
|
|
158
|
-
// state loaders): a truncated index once read as an empty pool would send
|
|
159
|
-
// `init` down first-time onboarding and overwrite it, orphaning every parked
|
|
160
|
-
// credential. Damaged state is the user's to repair, loudly.
|
|
161
117
|
if (!existsSync(paths.accountsJson)) return emptyIndex();
|
|
162
118
|
let json: unknown;
|
|
163
119
|
try {
|
|
@@ -176,8 +132,6 @@ export function saveAccounts(idx: AccountsIndex): void {
|
|
|
176
132
|
writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
177
133
|
}
|
|
178
134
|
|
|
179
|
-
// ---- usage.json ----------------------------------------------------------
|
|
180
|
-
|
|
181
135
|
export function loadUsage(): UsageState | null {
|
|
182
136
|
if (!existsSync(paths.usageJson)) return null;
|
|
183
137
|
try {
|
|
@@ -188,17 +142,11 @@ export function loadUsage(): UsageState | null {
|
|
|
188
142
|
}
|
|
189
143
|
}
|
|
190
144
|
|
|
191
|
-
/** Drop the statusLine-fed snapshots after a swap: their windows belong to the
|
|
192
|
-
* pre-swap account and would otherwise be read under the new active org. */
|
|
193
145
|
export function clearUsageSnapshots(): void {
|
|
194
146
|
rmSync(paths.usageJson, { force: true });
|
|
195
147
|
rmSync(paths.modelUsageJson, { force: true });
|
|
196
148
|
}
|
|
197
149
|
|
|
198
|
-
// ---- lastswap.json (epoch ms of the last swap; absent = never swapped;
|
|
199
|
-
// present-but-corrupt THROWS - silently reading a damaged swap clock as
|
|
200
|
-
// never-swapped would bypass the post-swap cooldown) ----
|
|
201
|
-
|
|
202
150
|
export function loadLastSwapAt(): number | null {
|
|
203
151
|
if (!existsSync(paths.lastSwapJson)) return null;
|
|
204
152
|
let json: unknown;
|
|
@@ -214,16 +162,6 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
214
162
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
215
163
|
}
|
|
216
164
|
|
|
217
|
-
// ---- depleted.json (the last depleted-wait decision; absent = none) ----
|
|
218
|
-
// Written on every depleted-wait so hooks that hit an early exit (post-swap
|
|
219
|
-
// cooldown, raced re-check, cleared snapshots) can REPLAY the wait to their
|
|
220
|
-
// own supervisor: without the replay only the first session's Stop hook ever
|
|
221
|
-
// saw a marker-writable decision and sibling sessions never paused (DESIGN.md
|
|
222
|
-
// 3.5's fan-out). The record dies three ways: waitUntil passing, the live
|
|
223
|
-
// seat (claude's oauthAccount) moving off the recorded account, and ANY
|
|
224
|
-
// completed swap clearing it outright (performSwap - the depleted path
|
|
225
|
-
// re-records its own wait right after its pre-park swap returns).
|
|
226
|
-
|
|
227
165
|
const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
|
|
228
166
|
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
229
167
|
|
|
@@ -246,23 +184,36 @@ export function clearDepletedWait(): void {
|
|
|
246
184
|
rmSync(paths.depletedJson, { force: true });
|
|
247
185
|
}
|
|
248
186
|
|
|
249
|
-
|
|
250
|
-
|
|
187
|
+
export const MAX_CHECK_DELAY_MS = 300_000;
|
|
188
|
+
|
|
189
|
+
export function loadNextCheckDueAt(now: number): number | null {
|
|
190
|
+
if (!existsSync(paths.nextCheckJson)) return null;
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = NextCheckSchema.safeParse(JSON.parse(readFileSync(paths.nextCheckJson, "utf8")));
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
if (!parsed.success) return null;
|
|
198
|
+
return parsed.data.dueAt - now > MAX_CHECK_DELAY_MS ? null : parsed.data.dueAt;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
|
|
202
|
+
writeFileAtomic(paths.nextCheckJson, JSON.stringify(NextCheckSchema.parse(input)));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function clearNextCheck(): void {
|
|
206
|
+
rmSync(paths.nextCheckJson, { force: true });
|
|
207
|
+
}
|
|
208
|
+
|
|
251
209
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
252
210
|
|
|
253
|
-
/** Write-on-change: skip the write (and its fsync) when only `ts` would differ,
|
|
254
|
-
* unless the stored `ts` has aged past the refresh window. A suppressed write
|
|
255
|
-
* still bumps the file's mtime (metadata only, no fsync): mtime is the feed's
|
|
256
|
-
* liveness heartbeat, and without the bump an alive tee re-proving unchanged
|
|
257
|
-
* figures reads as a dead feed and the decision path goes model-blind. */
|
|
258
211
|
export function writeUsage(next: UsageState): boolean {
|
|
259
212
|
const prev = loadUsage();
|
|
260
213
|
if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) {
|
|
261
214
|
try {
|
|
262
215
|
utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
|
|
263
216
|
} catch (e) {
|
|
264
|
-
// The file vanished mid-race: a concurrent swap just invalidated these
|
|
265
|
-
// figures. Suppressing stays correct; a write would resurrect them.
|
|
266
217
|
const errno = z.object({ code: z.string() }).safeParse(e);
|
|
267
218
|
if (!errno.success || errno.data.code !== "ENOENT") throw e;
|
|
268
219
|
}
|
|
@@ -272,9 +223,6 @@ export function writeUsage(next: UsageState): boolean {
|
|
|
272
223
|
return true;
|
|
273
224
|
}
|
|
274
225
|
|
|
275
|
-
/** When the usage feed last proved itself alive (usage.json mtime), null if the
|
|
276
|
-
* snapshot is absent. Fresher than the embedded `ts`, which write-on-change
|
|
277
|
-
* deliberately lets age while figures hold still. */
|
|
278
226
|
export function usageTeeAt(): number | null {
|
|
279
227
|
try {
|
|
280
228
|
return statSync(paths.usageJson).mtimeMs;
|
|
@@ -283,8 +231,6 @@ export function usageTeeAt(): number | null {
|
|
|
283
231
|
}
|
|
284
232
|
}
|
|
285
233
|
|
|
286
|
-
// ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
|
|
287
|
-
|
|
288
234
|
export function loadModelUsage(): ModelUsageState | null {
|
|
289
235
|
if (!existsSync(paths.modelUsageJson)) return null;
|
|
290
236
|
try {
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,24 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// caller - the Stop hook or a CLI command). The keychain/json writes additionally
|
|
3
|
-
// run under claude's own refresh lock so they can't interleave with a token refresh.
|
|
4
|
-
//
|
|
5
|
-
// resolve the live credential's TRUE owner (network, no lock; an expiring
|
|
6
|
-
// live credential refreshes under claude's refresh lock first)
|
|
7
|
-
// refresh B's parked credential (network, no lock), persisting the rotation
|
|
8
|
-
// at once - UNLESS B IS the live owner (label drift): then the live blob is
|
|
9
|
-
// already the newest rotation and the parked copy must not be refreshed
|
|
10
|
-
// ── under claude refresh lock ──
|
|
11
|
-
// verify the live item still holds the token the owner was resolved from
|
|
12
|
-
// (a concurrent /login or refresh in the unlocked gap aborts the swap)
|
|
13
|
-
// harvest live → its OWNER's backup (mandatory: refresh token rotates in
|
|
14
|
-
// place; when the owner IS the target this repairs its stale backup)
|
|
15
|
-
// install B into the live item
|
|
16
|
-
// rewrite oauthAccount in ~/.claude.json
|
|
17
|
-
// mark B active (kept adjacent to the identity write: the files cannot be
|
|
18
|
-
// crash-atomic together, but the next swap's true-owner resolution catches
|
|
19
|
-
// and logs any crash drift - swap.harvest_drift)
|
|
20
|
-
|
|
21
|
-
import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
1
|
+
import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
22
2
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
23
3
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
24
4
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
@@ -31,27 +11,12 @@ function parseBlob(raw: string) {
|
|
|
31
11
|
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
32
12
|
}
|
|
33
13
|
|
|
34
|
-
/**
|
|
35
|
-
* Mechanically switch the LIVE credential to `target`. Assumes the caller holds
|
|
36
|
-
* the tokenmaxxing flock. Throws InvalidGrantError (after marking needs_reauth)
|
|
37
|
-
* when target's refresh token is dead.
|
|
38
|
-
*/
|
|
39
14
|
export async function performSwap(target: Account): Promise<void> {
|
|
40
15
|
const idx = loadAccounts();
|
|
41
16
|
|
|
42
|
-
// 1. resolve the live credential's TRUE owner - the harvest destination -
|
|
43
|
-
// BEFORE any rotation. activeAccountUuid is a label, and labels drift from
|
|
44
|
-
// the blob they describe (crash mid-swap, manual /login, historical
|
|
45
|
-
// re-init); harvesting by label is how a backup once got destroyed, and
|
|
46
|
-
// refreshing the target's parked copy while the target is secretly the
|
|
47
|
-
// LIVE account would rotate a superseded grant and flag a healthy account
|
|
48
|
-
// needs-reauth (review catch, iteration 3). The token itself cannot lie.
|
|
49
17
|
const preLive = await readItem(liveTarget());
|
|
50
18
|
let liveOwner: Account | null = null;
|
|
51
19
|
let liveCreds: OAuthCreds | null = null;
|
|
52
|
-
// What the live item's accessToken must still be when the critical section
|
|
53
|
-
// below re-reads it: the owner resolution here happens UNLOCKED, so a change
|
|
54
|
-
// in between means the resolved owner may describe a different credential.
|
|
55
20
|
let expectedLiveToken: string | null = null;
|
|
56
21
|
if (preLive) {
|
|
57
22
|
liveCreds = parseBlob(preLive).claudeAiOauth;
|
|
@@ -59,7 +24,6 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
59
24
|
if (isAccessTokenExpiring(liveCreds, 60_000)) {
|
|
60
25
|
try {
|
|
61
26
|
await withClaudeRefreshLock(async (lock) => {
|
|
62
|
-
// re-read inside the lock: claude may have rotated it while we waited.
|
|
63
27
|
const raw2 = await readItem(liveTarget());
|
|
64
28
|
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
65
29
|
const current = parseBlob(raw2).claudeAiOauth;
|
|
@@ -72,9 +36,6 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
72
36
|
});
|
|
73
37
|
} catch (e) {
|
|
74
38
|
if (!(e instanceof InvalidGrantError)) throw e;
|
|
75
|
-
// dead credential family: nothing worth preserving, skip the harvest.
|
|
76
|
-
// expectedLiveToken keeps the on-disk token - the failed refresh wrote
|
|
77
|
-
// nothing, so the blob is unchanged until someone else changes it.
|
|
78
39
|
liveCreds = null;
|
|
79
40
|
log("swap.harvest_skipped_dead_live", {});
|
|
80
41
|
}
|
|
@@ -96,14 +57,6 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
96
57
|
}
|
|
97
58
|
}
|
|
98
59
|
|
|
99
|
-
// 2. the credential to install. When the target IS the live owner (label
|
|
100
|
-
// drift made us "swap onto" the account already live), the live item holds
|
|
101
|
-
// the newest rotation and NOTHING must be installed over it - refreshing
|
|
102
|
-
// the parked copy would rotate a superseded grant, and installing any
|
|
103
|
-
// pre-lock snapshot could clobber a rotation claude makes meanwhile; the
|
|
104
|
-
// harvest below repairs the stale backup and the label commit repairs the
|
|
105
|
-
// drift. Otherwise refresh the parked credential and persist the rotation
|
|
106
|
-
// before ANY later step can fail (mirrors codexswap).
|
|
107
60
|
const selfSwap = liveOwner != null && liveOwner.accountUuid === target.accountUuid;
|
|
108
61
|
let fresh: OAuthCreds | null = null;
|
|
109
62
|
if (!selfSwap) {
|
|
@@ -122,74 +75,37 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
122
75
|
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
123
76
|
}
|
|
124
77
|
|
|
125
|
-
// 3. the fast, local, atomic-vs-claude-refresh critical section.
|
|
126
78
|
await withClaudeRefreshLock(async (lock) => {
|
|
127
79
|
const currentLive = await readItem(liveTarget());
|
|
128
80
|
if (lock.compromised()) throw new Error("refresh lock compromised - aborting the swap before any write");
|
|
129
81
|
|
|
130
|
-
// The owner above was resolved UNLOCKED (network calls must not sit inside
|
|
131
|
-
// claude's refresh lock), so a manual /login or a claude refresh can have
|
|
132
|
-
// replaced the live item since. Harvesting the replaced blob under the
|
|
133
|
-
// stale owner would corrupt that owner's only backup - the exact incident
|
|
134
|
-
// class the owner-first order exists to prevent (review catch, PR #31).
|
|
135
|
-
// Any change aborts before a single write; the next check re-resolves the
|
|
136
|
-
// true owner and retries.
|
|
137
82
|
const currentToken = currentLive == null ? null : parseBlob(currentLive).claudeAiOauth.accessToken;
|
|
138
83
|
if (currentToken !== expectedLiveToken) {
|
|
139
84
|
throw new Error("live credential changed while unlocked (concurrent /login or refresh) - aborting this swap; the next check re-resolves the owner and retries");
|
|
140
85
|
}
|
|
141
86
|
|
|
142
|
-
// harvest the live claudeAiOauth into its OWNER's (small) backup item.
|
|
143
|
-
// When the owner IS the target (label drift), the live blob is the newest
|
|
144
|
-
// rotation - the parked refresh was skipped above - so this same write is
|
|
145
|
-
// exactly the repair of a stale or dead parked backup.
|
|
146
87
|
if (liveOwner && currentLive) {
|
|
147
88
|
await writeItem(parkedTarget(liveOwner.keychainItem), claudeAiOauthOnly(currentLive));
|
|
148
89
|
log("swap.harvest", { account: liveOwner.accountUuid.slice(0, 8) });
|
|
149
90
|
}
|
|
150
91
|
|
|
151
|
-
// install B: merge B's fresh claudeAiOauth into the CURRENT live blob so all
|
|
152
|
-
// sibling state (MCP OAuth tokens, etc.) is preserved across the swap.
|
|
153
|
-
// (B's rotation was already persisted to its backup right after the refresh.)
|
|
154
|
-
// A self-swap installs NOTHING: the live item already holds the newest
|
|
155
|
-
// rotation, re-read under this lock; only the label below needs repair.
|
|
156
92
|
if (fresh != null) {
|
|
157
93
|
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
158
94
|
}
|
|
159
95
|
swapOAuthAccount(target.oauthAccount);
|
|
160
|
-
// record B as active immediately after the identity write. These separate
|
|
161
|
-
// files cannot be crash-atomic together, so a crash may leave intermediate
|
|
162
|
-
// state; the next swap resolves the live owner from the token itself and
|
|
163
|
-
// logs any drift (swap.harvest_drift).
|
|
164
96
|
idx.activeAccountUuid = target.accountUuid;
|
|
165
97
|
const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
166
98
|
if (t2) { t2.needsReauth = false; }
|
|
167
99
|
saveAccounts(idx);
|
|
168
|
-
// the snapshots on disk still describe the pre-swap account; under the new
|
|
169
|
-
// org label they'd trigger a bogus switch off the account just installed.
|
|
170
100
|
clearUsageSnapshots();
|
|
171
|
-
// any completed swap supersedes a recorded depleted-wait: an unexpired
|
|
172
|
-
// stale record could otherwise replay through this swap's own cooldown
|
|
173
|
-
// and pause the fresh seat for a long-gone decision (review catch,
|
|
174
|
-
// PR #31). The depleted path re-records its own wait right after its
|
|
175
|
-
// pre-park swap returns.
|
|
176
101
|
clearDepletedWait();
|
|
102
|
+
clearNextCheck();
|
|
177
103
|
saveLastSwapAt(Date.now());
|
|
178
104
|
});
|
|
179
105
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
180
106
|
}
|
|
181
107
|
|
|
182
|
-
/**
|
|
183
|
-
* Pick the best candidate and swap to it, retrying past dead refresh tokens.
|
|
184
|
-
* Assumes the caller holds the flock (does NOT lock - avoids same-process
|
|
185
|
-
* flock self-deadlock). Returns the account landed on, or null if none usable.
|
|
186
|
-
*/
|
|
187
108
|
export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
|
|
188
|
-
// ctx.currentAccountUuid is the caller-resolved SEAT (decide.ts resolves it
|
|
189
|
-
// by the live org, label fallback): resolving here off activeAccountUuid
|
|
190
|
-
// re-imported the label drift the caller just resolved away, and under
|
|
191
|
-
// drift the LIVE account could be picked as its own swap target (bugbot
|
|
192
|
-
// review catch, PR #33).
|
|
193
109
|
const tried = new Set<string>();
|
|
194
110
|
while (true) {
|
|
195
111
|
const idx = loadAccounts();
|
|
@@ -201,7 +117,7 @@ export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
|
|
|
201
117
|
await performSwap(best);
|
|
202
118
|
return best;
|
|
203
119
|
} catch (e) {
|
|
204
|
-
if (e instanceof InvalidGrantError) continue;
|
|
120
|
+
if (e instanceof InvalidGrantError) continue;
|
|
205
121
|
throw e;
|
|
206
122
|
}
|
|
207
123
|
}
|
package/src/lib/tty.ts
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// Save/restore the controlling terminal's line settings around a child that owns
|
|
2
|
-
// the tty via inherited stdio. If we SIGTERM such a child (supervisor respawn, or
|
|
3
|
-
// `add` auto-exit), the terminal can be left in raw mode; restoring stty fixes it.
|
|
4
|
-
|
|
5
1
|
export function saveTermios(): string | null {
|
|
6
2
|
const p = Bun.spawnSync(["/bin/sh", "-c", "stty -g </dev/tty"]);
|
|
7
3
|
const s = p.stdout?.toString().trim();
|