tokenmaxxing 1.56.0 → 1.58.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 +7 -7
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/cli/auth.ts +6 -17
- package/src/cli/check.ts +2 -2
- package/src/cli/cloudrun.ts +12 -19
- package/src/cli/config.ts +2 -1
- package/src/cli/doctor.ts +5 -4
- package/src/cli/init.ts +0 -1
- package/src/cli/render.ts +24 -9
- package/src/cli/rm.ts +0 -4
- package/src/cli/status.ts +34 -57
- package/src/entries/codexstophook.ts +10 -23
- package/src/entries/codexsupervisor.ts +30 -72
- package/src/entries/statusline.ts +17 -26
- package/src/entries/stopfailurehook.ts +4 -5
- package/src/entries/stophook.ts +12 -10
- package/src/entries/subagentstatusline.ts +4 -10
- package/src/entries/supervisor.ts +91 -116
- package/src/lib/claude.ts +11 -12
- package/src/lib/claudebin.ts +3 -2
- package/src/lib/claudelock.ts +2 -2
- package/src/lib/cloud.ts +4 -8
- package/src/lib/codex.ts +3 -3
- package/src/lib/codexauth.ts +2 -11
- package/src/lib/codexbin.ts +2 -1
- package/src/lib/codexoauth.ts +3 -8
- package/src/lib/codexusage.ts +5 -5
- package/src/lib/compact.ts +5 -18
- package/src/lib/credstore.ts +1 -6
- package/src/lib/decide.ts +6 -24
- package/src/lib/grok.ts +38 -152
- package/src/lib/http.ts +1 -2
- package/src/lib/install.ts +20 -38
- package/src/lib/keychain.ts +1 -4
- package/src/lib/log.ts +5 -5
- package/src/lib/oauth.ts +6 -5
- package/src/lib/opencodego.ts +31 -140
- package/src/lib/paths.ts +6 -22
- package/src/lib/picker.ts +9 -20
- package/src/lib/presence.ts +4 -5
- package/src/lib/proc.ts +22 -0
- package/src/lib/sample.ts +25 -24
- package/src/lib/sessions.ts +5 -6
- package/src/lib/settings.ts +1 -8
- package/src/lib/state.ts +6 -29
- package/src/lib/statusonly.ts +143 -0
- package/src/lib/supervise.ts +111 -0
- package/src/lib/types.ts +27 -36
- package/src/lib/update.ts +6 -7
- package/src/lib/usage.ts +40 -191
- package/src/main.ts +5 -5
- package/src/entries/sessionstart.ts +0 -33
package/src/lib/opencodego.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import { opencodeGoAuthJsonFor, opencodeGoPaths, opencodeGoPool, opencodeGoStoreDirFor } from "./paths.ts";
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
9
|
-
import type
|
|
7
|
+
import type { Provider } from "./provider.ts";
|
|
8
|
+
import { type Harvest } from "./state.ts";
|
|
9
|
+
import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
|
|
10
10
|
import { c } from "../cli/render.ts";
|
|
11
11
|
|
|
12
12
|
const OpencodeAuthEntrySchema = z.looseObject({ type: z.string(), key: z.string().optional() });
|
|
@@ -20,77 +20,12 @@ function liveAuthPath(): string {
|
|
|
20
20
|
return join(dataHome(), "opencode", "auth.json");
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function parseAuthFile(path: string): { provider: string; key: string } | null {
|
|
24
|
-
let raw: unknown;
|
|
25
|
-
try {
|
|
26
|
-
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
const map = OpencodeAuthFileSchema.safeParse(raw);
|
|
31
|
-
if (!map.success) return null;
|
|
32
|
-
const entry = OpencodeAuthEntrySchema.safeParse(map.data["opencode-go"]);
|
|
33
|
-
if (!entry.success || entry.data.type !== "api" || !entry.data.key) return null;
|
|
34
|
-
return { provider: "opencode-go", key: entry.data.key };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
23
|
function idOfKey(key: string): string {
|
|
38
24
|
const h = new Bun.CryptoHasher("sha256");
|
|
39
25
|
h.update(`opencode-go:${key}`);
|
|
40
26
|
return `opencode-go-${h.digest("hex")}`;
|
|
41
27
|
}
|
|
42
28
|
|
|
43
|
-
function resolveRealOpencode(): string {
|
|
44
|
-
const cfg = loadConfig();
|
|
45
|
-
if (cfg.opencodeBin) {
|
|
46
|
-
if (!existsSync(cfg.opencodeBin)) throw new Error(`configured opencodeBin does not exist: ${cfg.opencodeBin} - fix config.json`);
|
|
47
|
-
return cfg.opencodeBin;
|
|
48
|
-
}
|
|
49
|
-
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
50
|
-
if (!d) continue;
|
|
51
|
-
const cand = join(d, "opencode");
|
|
52
|
-
try {
|
|
53
|
-
if (existsSync(cand)) return cand;
|
|
54
|
-
} catch {
|
|
55
|
-
continue;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
throw new Error("could not locate the real `opencode` binary (set opencodeBin in config.json)");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function liveId(): string | null {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function presence(): Map<string, number> {
|
|
66
|
-
return new Map();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function observeLive(account: Account): Promise<Observation | null> {
|
|
70
|
-
return account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
|
|
74
|
-
const reports = new Map<string, SampleReport>();
|
|
75
|
-
for (const a of accounts) {
|
|
76
|
-
reports.set(a.id, (await storeUsable(a)) ? { ok: true, source: "probe" } : { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --opencode-go`" });
|
|
77
|
-
}
|
|
78
|
-
return reports;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
async function storeUsable(a: Account): Promise<boolean> {
|
|
82
|
-
try {
|
|
83
|
-
const found = parseAuthFile(opencodeGoAuthJsonFor(a.id));
|
|
84
|
-
return found != null && idOfKey(found.key) === a.id;
|
|
85
|
-
} catch {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
async function removeCredentials(a: Account): Promise<void> {
|
|
91
|
-
rmSync(opencodeGoStoreDirFor(a.id), { recursive: true, force: true });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
29
|
function harvestOf(key: string): Harvest {
|
|
95
30
|
const id = idOfKey(key);
|
|
96
31
|
return {
|
|
@@ -105,82 +40,38 @@ function harvestOf(key: string): Harvest {
|
|
|
105
40
|
};
|
|
106
41
|
}
|
|
107
42
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const onboardDir = opencodeGoPaths.onboardDir;
|
|
111
|
-
rmSync(onboardDir, { recursive: true, force: true });
|
|
112
|
-
mkdirSync(onboardDir, { recursive: true });
|
|
113
|
-
const p = Bun.spawn([real, "providers", "login", "-p", "opencode-go"], {
|
|
114
|
-
stdin: "inherit",
|
|
115
|
-
stdout: "inherit",
|
|
116
|
-
stderr: "inherit",
|
|
117
|
-
env: { ...process.env, XDG_DATA_HOME: onboardDir, TOKENMAXXING_PROBE: "1" },
|
|
118
|
-
});
|
|
119
|
-
await p.exited;
|
|
43
|
+
function readAuth(path: string): AuthEntry[] {
|
|
44
|
+
let raw: unknown;
|
|
120
45
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
const found = parseAuthFile(join(onboardDir, "opencode", "auth.json"));
|
|
126
|
-
if (!found) {
|
|
127
|
-
console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
|
|
128
|
-
return null;
|
|
129
|
-
}
|
|
130
|
-
return harvestOf(found.key);
|
|
131
|
-
} finally {
|
|
132
|
-
rmSync(onboardDir, { recursive: true, force: true });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
async function importLive(): Promise<Harvest | null> {
|
|
137
|
-
console.log(c.cyan("Pooling your opencode-go API key - your existing opencode auth stays as it is."));
|
|
138
|
-
console.log();
|
|
139
|
-
const found = parseAuthFile(liveAuthPath());
|
|
140
|
-
if (found) {
|
|
141
|
-
console.log(c.dim(`found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`));
|
|
142
|
-
return harvestOf(found.key);
|
|
143
|
-
}
|
|
144
|
-
return login();
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function preflight(): void {
|
|
148
|
-
const real = resolveRealOpencode();
|
|
149
|
-
const p = Bun.spawnSync([real, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 15_000 });
|
|
150
|
-
const out = (p.stdout?.toString() ?? "").trim();
|
|
151
|
-
if (p.exitCode !== 0 || out === "") {
|
|
152
|
-
throw new Error(`opencode binary failed verification: ${real}`);
|
|
46
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
153
49
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
50
|
+
const map = OpencodeAuthFileSchema.safeParse(raw);
|
|
51
|
+
if (!map.success) return [];
|
|
52
|
+
const entry = OpencodeAuthEntrySchema.safeParse(map.data["opencode-go"]);
|
|
53
|
+
if (!entry.success || entry.data.type !== "api" || !entry.data.key) return [];
|
|
54
|
+
return [{ id: idOfKey(entry.data.key), usable: true, harvest: harvestOf(entry.data.key) }];
|
|
159
55
|
}
|
|
160
56
|
|
|
161
|
-
export const opencodeGo: Provider = {
|
|
57
|
+
export const opencodeGo: Provider = statusOnlyProvider({
|
|
162
58
|
name: "opencode-go",
|
|
163
59
|
flag: " --opencode-go",
|
|
164
60
|
pool: opencodeGoPool,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
login,
|
|
181
|
-
importLive,
|
|
182
|
-
preflight,
|
|
183
|
-
install,
|
|
61
|
+
binName: "opencode",
|
|
62
|
+
binKey: "opencodeBin",
|
|
63
|
+
storeDirFor: opencodeGoStoreDirFor,
|
|
64
|
+
authJsonFor: opencodeGoAuthJsonFor,
|
|
65
|
+
onboardDir: opencodeGoPaths.onboardDir,
|
|
66
|
+
homeEnv: "XDG_DATA_HOME",
|
|
67
|
+
loginArgs: ["providers", "login", "-p", "opencode-go"],
|
|
68
|
+
onboardAuthRel: join("opencode", "auth.json"),
|
|
69
|
+
liveAuthPath,
|
|
70
|
+
readAuth,
|
|
71
|
+
liveId: () => null,
|
|
72
|
+
versionOk: (out) => out.trim() !== "",
|
|
73
|
+
importIntro: "Pooling your opencode-go API key - your existing opencode auth stays as it is.",
|
|
74
|
+
foundLive: () => `found an opencode-go credential in ${liveAuthPath()} - pooling it; use \`tokenmaxxing add --opencode-go\` for more keys.`,
|
|
75
|
+
installNotice: "opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys.",
|
|
184
76
|
loginStep: () => `Paste the opencode-go API key from ${c.bold("opencode.ai/zen")} when opencode asks for it.`,
|
|
185
|
-
|
|
186
|
-
};
|
|
77
|
+
});
|
package/src/lib/paths.ts
CHANGED
|
@@ -6,8 +6,12 @@ const HOME = homedir();
|
|
|
6
6
|
|
|
7
7
|
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
8
8
|
|
|
9
|
+
export function optionalEnv(name: string, source: Record<string, string | undefined> = process.env): string | undefined {
|
|
10
|
+
return EnvOverrideSchema.parse(source[name]);
|
|
11
|
+
}
|
|
12
|
+
|
|
9
13
|
export function env(name: string, fallback: string): string {
|
|
10
|
-
return
|
|
14
|
+
return optionalEnv(name) ?? fallback;
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
|
|
@@ -42,29 +46,25 @@ export const paths = {
|
|
|
42
46
|
|
|
43
47
|
export const claudePool = {
|
|
44
48
|
accountsJson: join(TM_HOME, "accounts.json"),
|
|
45
|
-
lastSwapJson: null,
|
|
46
49
|
lockFile: join(TM_HOME, "lock"),
|
|
47
50
|
} as const;
|
|
48
51
|
|
|
49
52
|
export const codexPool = {
|
|
50
53
|
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
51
|
-
lastSwapJson: null,
|
|
52
54
|
lockFile: join(TM_HOME, "codex-lock"),
|
|
53
55
|
} as const;
|
|
54
56
|
|
|
55
57
|
export const grokPool = {
|
|
56
58
|
accountsJson: join(TM_HOME, "grok-accounts.json"),
|
|
57
|
-
lastSwapJson: null,
|
|
58
59
|
lockFile: join(TM_HOME, "grok-lock"),
|
|
59
60
|
} as const;
|
|
60
61
|
|
|
61
62
|
export const opencodeGoPool = {
|
|
62
63
|
accountsJson: join(TM_HOME, "opencode-go-accounts.json"),
|
|
63
|
-
lastSwapJson: null,
|
|
64
64
|
lockFile: join(TM_HOME, "opencode-go-lock"),
|
|
65
65
|
} as const;
|
|
66
66
|
|
|
67
|
-
export type PoolPaths = { accountsJson: string;
|
|
67
|
+
export type PoolPaths = { accountsJson: string; lockFile: string };
|
|
68
68
|
|
|
69
69
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
70
70
|
|
|
@@ -159,20 +159,4 @@ export function namespacedCredService(configDirRaw: string): string {
|
|
|
159
159
|
return `Claude Code-credentials-${h.digest("hex").slice(0, 8)}`;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
export function realClaudeBinFromEnv(): string | undefined {
|
|
163
|
-
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CLAUDE_BIN);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function realCodexBinFromEnv(): string | undefined {
|
|
167
|
-
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function realGrokBinFromEnv(): string | undefined {
|
|
171
|
-
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_GROK_BIN);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function realOpencodeBinFromEnv(): string | undefined {
|
|
175
|
-
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_OPENCODE_BIN);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
162
|
export { HOME };
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { maxBy, minBy, sortBy } from "es-toolkit";
|
|
2
|
-
import { z } from "zod";
|
|
3
2
|
import { familyTokens } from "./usage.ts";
|
|
4
|
-
import {
|
|
3
|
+
import type { Account, Config, Thresholds, Window } from "./types.ts";
|
|
5
4
|
|
|
6
5
|
export function thresholdBars(cfg: Config): Thresholds {
|
|
7
6
|
return {
|
|
@@ -10,14 +9,13 @@ export function thresholdBars(cfg: Config): Thresholds {
|
|
|
10
9
|
};
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
|
|
14
|
-
now:
|
|
15
|
-
thresholds:
|
|
16
|
-
currentId:
|
|
17
|
-
families:
|
|
18
|
-
seats:
|
|
19
|
-
}
|
|
20
|
-
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
12
|
+
export type PickCtx = {
|
|
13
|
+
now: number;
|
|
14
|
+
thresholds: Thresholds;
|
|
15
|
+
currentId: string | null;
|
|
16
|
+
families: string[] | null;
|
|
17
|
+
seats: Map<string, number> | null;
|
|
18
|
+
};
|
|
21
19
|
|
|
22
20
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
23
21
|
const SESSION_WINDOW_MAX_S = 6 * 3600;
|
|
@@ -123,21 +121,12 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
123
121
|
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
124
122
|
}
|
|
125
123
|
|
|
126
|
-
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx, margin = 1): boolean {
|
|
127
|
-
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
128
|
-
const best = pickBest(accounts, { ...ctx, currentId: null });
|
|
129
|
-
if (best == null || best.id === active.id) return true;
|
|
130
|
-
if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
|
|
131
|
-
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
132
|
-
}
|
|
133
|
-
|
|
134
124
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
|
135
125
|
const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
|
|
136
126
|
return blocking.length ? Math.max(...blocking) : ctx.now;
|
|
137
127
|
}
|
|
138
128
|
|
|
139
|
-
|
|
140
|
-
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
129
|
+
export type EarliestReset = { account: Account; availableAt: number };
|
|
141
130
|
|
|
142
131
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
143
132
|
const mapped = accounts
|
package/src/lib/presence.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
5
|
import { pidExists, pidStartTime } from "./proc.ts";
|
|
6
|
+
import { ErrnoSchema } from "./types.ts";
|
|
6
7
|
|
|
7
8
|
const PresenceSchema = z.object({
|
|
8
9
|
accountId: z.string(),
|
|
@@ -21,8 +22,7 @@ export function clearPresence(input: { dir: string; id: string }): void {
|
|
|
21
22
|
rmSync(join(input.dir, input.id), { force: true });
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
|
|
25
|
+
export type LivingPresence = { id: string; accountId: string };
|
|
26
26
|
|
|
27
27
|
export function livingPresences(dir: string): LivingPresence[] {
|
|
28
28
|
const living: LivingPresence[] = [];
|
|
@@ -33,8 +33,7 @@ export function livingPresences(dir: string): LivingPresence[] {
|
|
|
33
33
|
try {
|
|
34
34
|
raw = readFileSync(file, "utf8");
|
|
35
35
|
} catch (e) {
|
|
36
|
-
|
|
37
|
-
if (errno.success && errno.data.code === "ENOENT") continue;
|
|
36
|
+
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") continue;
|
|
38
37
|
throw e;
|
|
39
38
|
}
|
|
40
39
|
const parsed = PresenceSchema.safeParse((() => {
|
|
@@ -55,7 +54,7 @@ export function livingPresences(dir: string): LivingPresence[] {
|
|
|
55
54
|
rmSync(file, { force: true });
|
|
56
55
|
continue;
|
|
57
56
|
}
|
|
58
|
-
living.push(
|
|
57
|
+
living.push({ id: name, accountId: parsed.data.accountId });
|
|
59
58
|
}
|
|
60
59
|
return living;
|
|
61
60
|
}
|
package/src/lib/proc.ts
CHANGED
|
@@ -7,6 +7,28 @@ export function pidStartTime(pid: number): string | null {
|
|
|
7
7
|
return lstart === "" ? null : lstart;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
export async function readStdin(): Promise<string> {
|
|
11
|
+
const chunks: Uint8Array[] = [];
|
|
12
|
+
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
13
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function* readLines(stream: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
|
17
|
+
const decoder = new TextDecoder();
|
|
18
|
+
let pending = "";
|
|
19
|
+
for await (const chunk of stream) {
|
|
20
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
21
|
+
let nl = pending.indexOf("\n");
|
|
22
|
+
while (nl !== -1) {
|
|
23
|
+
yield pending.slice(0, nl);
|
|
24
|
+
pending = pending.slice(nl + 1);
|
|
25
|
+
nl = pending.indexOf("\n");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
pending += decoder.decode();
|
|
29
|
+
if (pending.length > 0) yield pending;
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
export function pidExists(pid: number): boolean {
|
|
11
33
|
try {
|
|
12
34
|
process.kill(pid, 0);
|
package/src/lib/sample.ts
CHANGED
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { z } from "zod";
|
|
4
3
|
import { writeFileAtomic } from "./atomic.ts";
|
|
5
4
|
import { readStore } from "./credstore.ts";
|
|
6
5
|
import { withLock } from "./lock.ts";
|
|
7
|
-
import { log } from "./log.ts";
|
|
6
|
+
import { errorMessage, log } from "./log.ts";
|
|
8
7
|
import { claudeTierLabel, isAccessTokenExpiring, isDeadCredential } from "./oauth.ts";
|
|
9
8
|
import { claudePool, paths, sampleDirFor, storeDirFor } from "./paths.ts";
|
|
10
9
|
import { seatCounts } from "./presence.ts";
|
|
11
10
|
import type { Observation } from "./provider.ts";
|
|
12
11
|
import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
|
|
13
|
-
import { fetchUsageDirect, mergeWindows,
|
|
14
|
-
import {
|
|
12
|
+
import { fetchUsageDirect, mergeWindows, refreshViaProbe, windowsOf } from "./usage.ts";
|
|
13
|
+
import type { Account, Config, UsageWindows } from "./types.ts";
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
z.object({ ok: z.literal(true), usage: UsageWindowsSchema, via: z.enum(["get", "probe"]) }),
|
|
18
|
-
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
19
|
-
]);
|
|
20
|
-
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
15
|
+
export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" | "probe" } | { ok: false; reason: string };
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
z.object({ ok: z.literal(true), token: z.string().nullable() }),
|
|
24
|
-
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
25
|
-
]);
|
|
26
|
-
type PreparedSample = z.infer<typeof PreparedSampleSchema>;
|
|
17
|
+
type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
|
|
27
18
|
|
|
28
19
|
export function teeObservation(account: Account): Observation | null {
|
|
29
20
|
const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
|
|
@@ -48,7 +39,7 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
|
|
|
48
39
|
try {
|
|
49
40
|
creds = await readStore(account.id);
|
|
50
41
|
} catch (e) {
|
|
51
|
-
return { ok: false, reason: `store credential unreadable (${(e
|
|
42
|
+
return { ok: false, reason: `store credential unreadable (${errorMessage(e).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
|
|
52
43
|
}
|
|
53
44
|
if (!creds) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth`" };
|
|
54
45
|
if (isDeadCredential(creds)) {
|
|
@@ -60,25 +51,29 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
|
|
|
60
51
|
return { ok: true, token: isAccessTokenExpiring(creds) ? null : creds.accessToken };
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
async function
|
|
54
|
+
async function refreshedToken(account: Account, suffix: string): Promise<string | null> {
|
|
64
55
|
const dir = sampleDirFor(account.id, suffix);
|
|
65
56
|
mkdirSync(dir, { recursive: true });
|
|
66
57
|
writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
if (!(await refreshViaProbe({ configDir: dir, store: storeDirFor(account.id) }))) return null;
|
|
59
|
+
const prepared = await prepareSample(account);
|
|
60
|
+
return prepared.ok ? prepared.token : null;
|
|
69
61
|
}
|
|
70
62
|
|
|
71
|
-
export async function runSample(account: Account, token: string | null,
|
|
63
|
+
export async function runSample(account: Account, token: string | null, suffix = ""): Promise<SampleOutcome> {
|
|
72
64
|
if (token) {
|
|
73
65
|
const usage = await fetchUsageDirect(token);
|
|
74
66
|
if (usage) return { ok: true, usage, via: "get" };
|
|
75
67
|
}
|
|
76
|
-
|
|
68
|
+
const fresh = await refreshedToken(account, suffix);
|
|
69
|
+
if (!fresh) return { ok: false, reason: "the `/usage` probe left no usable access token in the store (see log)" };
|
|
70
|
+
const usage = await fetchUsageDirect(fresh);
|
|
71
|
+
return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "usage read failed after the `/usage` probe (see log)" };
|
|
77
72
|
}
|
|
78
73
|
|
|
79
|
-
export async function sampleAccountUsage(account: Account
|
|
74
|
+
export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
|
|
80
75
|
const prepared = await prepareSample(account);
|
|
81
|
-
return prepared.ok ? runSample(account, prepared.token
|
|
76
|
+
return prepared.ok ? runSample(account, prepared.token) : prepared;
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
const SAMPLE_BATCH = 3;
|
|
@@ -119,15 +114,21 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
119
114
|
await Promise.all(
|
|
120
115
|
reserved.map(async ({ account, token }) => {
|
|
121
116
|
const startedAt = Date.now();
|
|
122
|
-
const outcome = await runSample(account, token,
|
|
117
|
+
const outcome = await runSample(account, token, "-tick");
|
|
123
118
|
await withLock(claudePool.lockFile, () => {
|
|
124
119
|
const idx = loadAccounts(claudePool);
|
|
125
120
|
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
121
|
+
let dirty = false;
|
|
122
|
+
if (stored && account.needsReauth === true && stored.needsReauth !== true) {
|
|
123
|
+
stored.needsReauth = true;
|
|
124
|
+
dirty = true;
|
|
125
|
+
}
|
|
126
126
|
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
127
127
|
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
128
128
|
stored.lastUsageAt = startedAt;
|
|
129
|
-
|
|
129
|
+
dirty = true;
|
|
130
130
|
}
|
|
131
|
+
if (dirty) saveAccounts(claudePool, idx);
|
|
131
132
|
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
132
133
|
account: account.id.slice(0, 8),
|
|
133
134
|
...(outcome.ok ? { via: outcome.via } : { reason: outcome.reason.slice(0, 200) }),
|
package/src/lib/sessions.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { paths } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
|
-
import { RespawnMarkerSchema } from "./types.ts";
|
|
6
|
+
import type { RespawnMarkerSchema } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
|
|
9
9
|
|
|
@@ -36,8 +36,7 @@ export function pruneStaleSessions(now: number): void {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
export type SupervisedSession = z.infer<typeof SupervisedSessionSchema>;
|
|
39
|
+
export type SupervisedSession = { sid: string; launchedAt: number | null };
|
|
41
40
|
|
|
42
41
|
const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
|
|
43
42
|
|
|
@@ -45,18 +44,18 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
|
|
|
45
44
|
if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
|
|
46
45
|
const sid = env.TOKENMAXXING_SESSION_ID;
|
|
47
46
|
if (sid == null || sid === "") return null;
|
|
48
|
-
return
|
|
47
|
+
return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null };
|
|
49
48
|
}
|
|
50
49
|
|
|
51
50
|
export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number; compact: boolean }): void {
|
|
52
51
|
mkdirSync(paths.respawnDir, { recursive: true });
|
|
53
|
-
const payload =
|
|
52
|
+
const payload: z.infer<typeof RespawnMarkerSchema> = {
|
|
54
53
|
accountId: input.accountId,
|
|
55
54
|
ts: Date.now(),
|
|
56
55
|
waitUntil: input.waitUntil,
|
|
57
56
|
sessionId: input.sessionId,
|
|
58
57
|
compact: input.compact,
|
|
59
58
|
...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
|
|
60
|
-
}
|
|
59
|
+
};
|
|
61
60
|
writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
|
|
62
61
|
}
|
package/src/lib/settings.ts
CHANGED
|
@@ -112,14 +112,7 @@ export function uninstallSettings(): void {
|
|
|
112
112
|
writeSettings(s);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
statusLineOk: z.boolean(),
|
|
117
|
-
subagentStatusLineOk: z.boolean(),
|
|
118
|
-
stopOk: z.boolean(),
|
|
119
|
-
stopFailureOk: z.boolean(),
|
|
120
|
-
sessionStartOk: z.boolean(),
|
|
121
|
-
});
|
|
122
|
-
export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
|
|
115
|
+
export type SettingsCheck = Record<"statusLineOk" | "subagentStatusLineOk" | "stopOk" | "stopFailureOk" | "sessionStartOk", boolean>;
|
|
123
116
|
|
|
124
117
|
export function checkSettings(): SettingsCheck {
|
|
125
118
|
const s = readSettings();
|
package/src/lib/state.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
2
|
import { isEqual } from "es-toolkit";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import {
|
|
4
|
+
import { optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import {
|
|
7
7
|
AccountsIndexSchema,
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
ErrnoSchema,
|
|
10
|
-
LastSwapSchema,
|
|
11
10
|
UsageStateSchema,
|
|
12
11
|
type Account,
|
|
13
12
|
type AccountsIndex,
|
|
@@ -31,14 +30,10 @@ export function loadConfig(): Config {
|
|
|
31
30
|
throw new Error(`${paths.configJson} is invalid (${fields}) - fix or remove the offending values`);
|
|
32
31
|
}
|
|
33
32
|
const cfg = parsed.data;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const envGrokBin = realGrokBinFromEnv();
|
|
39
|
-
if (envGrokBin) cfg.grokBin = envGrokBin;
|
|
40
|
-
const envOpencodeBin = realOpencodeBinFromEnv();
|
|
41
|
-
if (envOpencodeBin) cfg.opencodeBin = envOpencodeBin;
|
|
33
|
+
cfg.claudeBin = optionalEnv("TOKENMAXXING_CLAUDE_BIN") ?? cfg.claudeBin;
|
|
34
|
+
cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
|
|
35
|
+
cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
|
|
36
|
+
cfg.opencodeBin = optionalEnv("TOKENMAXXING_OPENCODE_BIN") ?? cfg.opencodeBin;
|
|
42
37
|
return cfg;
|
|
43
38
|
}
|
|
44
39
|
|
|
@@ -51,7 +46,7 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin" | "grokBin
|
|
|
51
46
|
writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
|
|
52
47
|
}
|
|
53
48
|
|
|
54
|
-
const emptyIndex = (): AccountsIndex => ({ version: 2,
|
|
49
|
+
const emptyIndex = (): AccountsIndex => ({ version: 2, accounts: [] });
|
|
55
50
|
|
|
56
51
|
export function loadAccounts(pool: PoolPaths): AccountsIndex {
|
|
57
52
|
if (!existsSync(pool.accountsJson)) return emptyIndex();
|
|
@@ -127,24 +122,6 @@ export function clearUsageSnapshot(accountId: string): void {
|
|
|
127
122
|
rmSync(usageJsonFor(accountId), { force: true });
|
|
128
123
|
}
|
|
129
124
|
|
|
130
|
-
export function loadLastSwapAt(pool: PoolPaths): number | null {
|
|
131
|
-
if (pool.lastSwapJson == null || !existsSync(pool.lastSwapJson)) return null;
|
|
132
|
-
let json: unknown;
|
|
133
|
-
try {
|
|
134
|
-
json = JSON.parse(readFileSync(pool.lastSwapJson, "utf8"));
|
|
135
|
-
} catch {
|
|
136
|
-
throw new Error(`${pool.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
|
|
137
|
-
}
|
|
138
|
-
return LastSwapSchema.parse(json).ts;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export function saveLastSwapAt(pool: PoolPaths, ts: number): void {
|
|
142
|
-
if (pool.lastSwapJson == null) throw new Error("this pool keeps no swap clock");
|
|
143
|
-
writeFileAtomic(pool.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
147
|
-
|
|
148
125
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
149
126
|
const SAMPLED_AT_REFRESH_MS = 30_000;
|
|
150
127
|
|