tokenmaxxing 1.42.0 → 1.44.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 CHANGED
@@ -57,7 +57,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
57
57
  4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
58
58
 
59
59
  ### 3.3 Move and depleted-pool pause (supervisor)
60
- The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The `SessionStart` hook (source `resume`) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker that fails to parse is never deleted and treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
60
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The `SessionStart` hook (source `resume`) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker that fails to parse is never deleted and treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
61
61
 
62
62
  ### 3.4 Placement (supervisor, under the pool lock)
63
63
  1. Fold every account's tee into the index.
@@ -93,7 +93,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
93
93
  ---
94
94
 
95
95
  ## 6. Honest papercuts
96
- - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped; bigger on long transcripts).
96
+ - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
97
97
  - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. Projected threshold reduces this.
98
98
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
99
99
  - **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/auth.ts CHANGED
@@ -8,7 +8,7 @@ import { usageNote } from "./add.ts";
8
8
  import { c, count } from "./render.ts";
9
9
  import type { Account, AccountsIndex } from "../lib/types.ts";
10
10
 
11
- const AUTH_USAGE = "usage: tokenmaxxing auth [--codex] [<email|label|id> | --all]";
11
+ const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok | --opencode-go] [<email|label|id> | --all]";
12
12
 
13
13
  const AuthPlanSchema = z.discriminatedUnion("kind", [
14
14
  z.object({ kind: z.literal("usage") }),
package/src/cli/init.ts CHANGED
@@ -8,7 +8,11 @@ import { c, count } from "./render.ts";
8
8
  export function printUsage(p: Provider): void {
9
9
  console.log();
10
10
  console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
11
- console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
11
+ if (p.statusOnly) {
12
+ console.log(` ${c.cyan(`xx status`)} view pooled ${p.name} accounts (status-only pool: no automatic switching yet)`);
13
+ } else {
14
+ console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
15
+ }
12
16
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
13
17
  console.log(` ${c.cyan(`xx add${p.flag}`)} log in and pool another account`);
14
18
  console.log(` ${c.cyan(`xx switch${p.flag}`)} hop to the best account right now (the automatic switching needs no command)`);
@@ -37,7 +41,7 @@ export async function cmdInit(p: Provider): Promise<number> {
37
41
  await harvested.park();
38
42
  const idx = loadAccounts(p.pool);
39
43
  const imported = upsertAccount(idx, harvested, p.mergeWindows);
40
- idx.activeId = p.seats === "live" ? harvested.id : null;
44
+ idx.activeId = !p.statusOnly && p.seats === "live" ? harvested.id : null;
41
45
  saveAccounts(p.pool, idx);
42
46
  return imported;
43
47
  });
package/src/cli/status.ts CHANGED
@@ -2,9 +2,11 @@ import { chunk, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { claude } from "../lib/claude.ts";
4
4
  import { codex } from "../lib/codex.ts";
5
+ import { grok } from "../lib/grok.ts";
6
+ import { opencodeGo } from "../lib/opencodego.ts";
5
7
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
6
8
  import { withLock } from "../lib/lock.ts";
7
- import { codexPool } from "../lib/paths.ts";
9
+ import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
8
10
  import { earliestReset, isExhausted, isSessionWindow, limitWindows, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
9
11
  import type { Provider, SampleReport } from "../lib/provider.ts";
10
12
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
@@ -52,7 +54,7 @@ const PoolReportSchema = z.object({
52
54
  });
53
55
  type PoolReport = z.infer<typeof PoolReportSchema>;
54
56
 
55
- const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema });
57
+ const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema, grok: PoolReportSchema, opencodeGo: PoolReportSchema });
56
58
  export type StatusReport = z.infer<typeof StatusReportSchema>;
57
59
 
58
60
  function currentWindow(w: Window, now: number): WindowReport {
@@ -239,9 +241,11 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
239
241
  const claudeReport = await collect(claude, cfg, now, cached);
240
242
  if (!json) {
241
243
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
244
+ const grokPooled = loadAccounts(grokPool).accounts.length > 0;
245
+ const opencodeGoPooled = loadAccounts(opencodeGoPool).accounts.length > 0;
242
246
  if (claudeReport.accounts.length === 0) {
243
- if (!codexPooled) {
244
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
247
+ if (!codexPooled && !grokPooled && !opencodeGoPooled) {
248
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--opencode-go`)"));
245
249
  return 0;
246
250
  }
247
251
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
@@ -252,13 +256,21 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
252
256
  }
253
257
  }
254
258
  const codexReport = await collect(codex, cfg, now, cached);
259
+ const grokReport = await collect(grok, cfg, now, cached);
260
+ const opencodeGoReport = await collect(opencodeGo, cfg, now, cached);
255
261
  if (json) {
256
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport };
262
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, opencodeGo: opencodeGoReport };
257
263
  emitJson({ ok: true, ...report });
258
264
  return 0;
259
265
  }
260
266
  if (codexReport.accounts.length > 0) {
261
267
  renderPool(codex, codexReport, `codex (${count({ n: codexReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
262
268
  }
269
+ if (grokReport.accounts.length > 0) {
270
+ renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
271
+ }
272
+ if (opencodeGoReport.accounts.length > 0) {
273
+ renderPool(opencodeGo, opencodeGoReport, `opencode-go (${count({ n: opencodeGoReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
274
+ }
263
275
  return 0;
264
276
  }
@@ -4,12 +4,15 @@ import { z } from "zod";
4
4
  import { codexPaths, codexPool } from "../lib/paths.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { writeFileAtomic } from "../lib/atomic.ts";
7
+ import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
7
8
  import { codex, codexPickCtx, reconcileSiblings } from "../lib/codex.ts";
8
9
  import { liveCodexAccountId } from "../lib/codexauth.ts";
10
+ import { resolveRealCodex } from "../lib/codexbin.ts";
11
+ import { compactCodexThread } from "../lib/compact.ts";
9
12
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
10
13
  import { isExhausted } from "../lib/picker.ts";
11
14
  import { livingPresences } from "../lib/presence.ts";
12
- import { loadAccounts } from "../lib/state.ts";
15
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadLastSwapAt } from "../lib/state.ts";
13
16
  import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
14
17
  import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, JsonTextSchema } from "../lib/types.ts";
15
18
  import { log } from "../lib/log.ts";
@@ -78,6 +81,22 @@ async function sweepSiblings(now: number): Promise<void> {
78
81
  }
79
82
  }
80
83
 
84
+ async function compactBeforeMove(input: { sessionId: string | null; now: number }): Promise<void> {
85
+ const { sessionId, now } = input;
86
+ if (sessionId == null || sessionId.trim() === "") return;
87
+ const lastSwapAt = loadLastSwapAt(codexPool);
88
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return;
89
+ const liveId = codex.liveId();
90
+ const live = loadAccounts(codexPool).accounts.find((a) => a.id === liveId);
91
+ if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
92
+ const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
93
+ if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
94
+ const env: Record<string, string | undefined> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
95
+ delete env[CODEX_SUPERVISOR_ID_ENV];
96
+ log("codexstop.compact_start", { thread: sessionId.slice(0, 8), account: live.id.slice(0, 8) });
97
+ await compactCodexThread({ real: resolveRealCodex(), threadId: sessionId, env });
98
+ }
99
+
81
100
  async function readStdin(): Promise<string> {
82
101
  const chunks: Uint8Array[] = [];
83
102
  for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
@@ -97,6 +116,7 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
97
116
  if (await promoteReconcile({ supervisorId, sessionId })) return;
98
117
  const now = Date.now();
99
118
  await withLock(codexPool.lockFile, () => reconcileSiblings(now));
119
+ await compactBeforeMove({ sessionId, now });
100
120
  const decision = await evaluateAndMaybeSwap(codex, now);
101
121
  if (decision.swapped && decision.account) {
102
122
  await sweepSiblings(now);
@@ -23,7 +23,7 @@ export async function runSessionStart(): Promise<number> {
23
23
  try {
24
24
  const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
25
25
  if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
26
- writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now() });
26
+ writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
27
27
  log("sessionstart.marker", { source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil });
28
28
  }
29
29
  } catch (e) {
@@ -54,7 +54,7 @@ export async function runStopFailureHook(): Promise<number> {
54
54
 
55
55
  const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced);
56
56
  if (enforced && session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
57
- writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now });
57
+ writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
58
58
  log("stopfailure.marker", { session: session.sid.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
59
59
  } else {
60
60
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
@@ -19,7 +19,7 @@ export async function runStopHook(): Promise<number> {
19
19
  try {
20
20
  const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
21
21
  if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
22
- writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now() });
22
+ writeRespawnMarker({ session, sessionId: stdinSid ?? session.sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
23
23
  log(decision.waitUntil !== undefined ? "stop.wait" : "stop.move", { account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8) });
24
24
  }
25
25
  } catch (e) {
@@ -6,6 +6,7 @@ import { z } from "zod";
6
6
  import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
7
7
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
8
8
  import { pickSeat } from "../lib/claude.ts";
9
+ import { compactClaudeSession } from "../lib/compact.ts";
9
10
  import { withLock } from "../lib/lock.ts";
10
11
  import { clearPresence, writePresence } from "../lib/presence.ts";
11
12
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
@@ -131,9 +132,16 @@ export function stripPositionals(argv: string[]): string[] {
131
132
  return out;
132
133
  }
133
134
 
135
+ function projectDirForCwd(): string {
136
+ return join(paths.claudeDir, "projects", process.cwd().replace(/[^a-zA-Z0-9]/g, "-"));
137
+ }
138
+
139
+ function transcriptPath(sessionId: string): string {
140
+ return join(projectDirForCwd(), `${sessionId}.jsonl`);
141
+ }
142
+
134
143
  function latestSessionForCwd(): string | null {
135
- const slug = process.cwd().replace(/[^a-zA-Z0-9]/g, "-");
136
- const projDir = join(paths.claudeDir, "projects", slug);
144
+ const projDir = projectDirForCwd();
137
145
  if (!existsSync(projDir)) return null;
138
146
  try {
139
147
  const files = readdirSync(projDir)
@@ -279,9 +287,9 @@ export async function runSupervisor(argv: string[]): Promise<number> {
279
287
  if (existsSync(marker)) rmSync(marker, { force: true });
280
288
 
281
289
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
282
- const child = await withLock(claudePool.lockFile, async () => {
283
- const seat = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
284
- log("supervisor.launch", { sid, respawns, seat: seat?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
290
+ const { child, seat } = await withLock(claudePool.lockFile, async () => {
291
+ const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
292
+ log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
285
293
  const spawned = Bun.spawn([real, ...launchArgs], {
286
294
  stdin: "inherit",
287
295
  stdout: "inherit",
@@ -291,11 +299,11 @@ export async function runSupervisor(argv: string[]): Promise<number> {
291
299
  TOKENMAXXING_SUPERVISED: "1",
292
300
  TOKENMAXXING_SESSION_ID: sid,
293
301
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
294
- ...(seat ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) } : {}),
302
+ ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
295
303
  },
296
304
  });
297
- if (seat) await recordPresence(spawned, sid, seat, savedTermios);
298
- return spawned;
305
+ if (picked) await recordPresence(spawned, sid, picked, savedTermios);
306
+ return { child: spawned, seat: picked };
299
307
  });
300
308
 
301
309
  let done = false;
@@ -326,6 +334,16 @@ export async function runSupervisor(argv: string[]): Promise<number> {
326
334
  rmSync(marker, { force: true });
327
335
  respawns++;
328
336
  const label = loadAccounts(claudePool).accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
337
+ if (m.compact && seat && existsSync(transcriptPath(m.sessionId))) {
338
+ process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`);
339
+ const compactEnv: Record<string, string | undefined> = { ...childEnv, TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
340
+ delete compactEnv.TOKENMAXXING_SUPERVISED;
341
+ delete compactEnv.TOKENMAXXING_SESSION_ID;
342
+ delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
343
+ const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
344
+ log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
345
+ if (!outcome.ok) process.stdout.write(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`);
346
+ }
329
347
  if (m.waitUntil > Date.now()) {
330
348
  if (await countdownWait(label, m.waitUntil)) overriddenUntil = m.waitUntil;
331
349
  } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`);
package/src/lib/claude.ts CHANGED
@@ -274,6 +274,7 @@ export const claude: Provider = {
274
274
  pool: claudePool,
275
275
  seats: "shared",
276
276
  waitsWhenDepleted: true,
277
+ statusOnly: false,
277
278
  liveId,
278
279
  presence,
279
280
  gatedFamilies: (cfg) => {
package/src/lib/codex.ts CHANGED
@@ -325,6 +325,7 @@ export const codex: Provider = {
325
325
  pool: codexPool,
326
326
  seats: "live",
327
327
  waitsWhenDepleted: false,
328
+ statusOnly: false,
328
329
  liveId: liveCodexAccountId,
329
330
  presence: () => seatCounts(codexPaths.presenceDir),
330
331
  gatedFamilies: () => null,
@@ -0,0 +1,136 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { delay } from "es-toolkit";
4
+ import { z } from "zod";
5
+ import { log } from "./log.ts";
6
+ import { JsonTextSchema } from "./types.ts";
7
+
8
+ const CompactOutcomeSchema = z.discriminatedUnion("ok", [
9
+ z.object({ ok: z.literal(true) }),
10
+ z.object({ ok: z.literal(false), reason: z.string() }),
11
+ ]);
12
+ export type CompactOutcome = z.infer<typeof CompactOutcomeSchema>;
13
+
14
+ export const CLAUDE_COMPACT_KILL_MS = 300_000;
15
+ const PIPE_GRACE_MS = 2_000;
16
+
17
+ export async function compactClaudeSession(input: { real: string; sid: string; env: Record<string, string | undefined> }): Promise<CompactOutcome> {
18
+ const p = Bun.spawn([input.real, "-p", "--resume", input.sid, "/compact"], {
19
+ env: input.env,
20
+ stdin: "ignore",
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ timeout: CLAUDE_COMPACT_KILL_MS,
24
+ killSignal: "SIGKILL",
25
+ });
26
+ const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
27
+ const settled = await Promise.race([reads, p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null)]);
28
+ await p.exited;
29
+ if (settled === null) return { ok: false, reason: "output pipes still open after child exit (leaked descendant)" };
30
+ const [stdout, stderr] = settled;
31
+ if (p.exitCode === 0) return { ok: true };
32
+ if (p.exitCode === null) return { ok: false, reason: `killed after ${CLAUDE_COMPACT_KILL_MS / 1000}s` };
33
+ return { ok: false, reason: `exit ${p.exitCode}: ${(stderr.trim() || stdout.trim()).slice(0, 200)}` };
34
+ }
35
+
36
+ export const CODEX_COMPACT_KILL_MS = 180_000;
37
+
38
+ const RpcLineSchema = z.looseObject({
39
+ id: z.union([z.number(), z.string()]).optional(),
40
+ method: z.string().optional(),
41
+ error: z.looseObject({ message: z.string().optional() }).optional(),
42
+ params: z.unknown().optional(),
43
+ });
44
+ const TurnCompletedSchema = z.looseObject({
45
+ threadId: z.string(),
46
+ turn: z.looseObject({ status: z.string(), error: z.looseObject({ message: z.string() }).nullable().optional() }),
47
+ });
48
+ const ErrorNotificationSchema = z.looseObject({ threadId: z.string().nullable().optional(), error: z.looseObject({ message: z.string().optional() }).optional() });
49
+
50
+ const INIT_ID = 1;
51
+ const RESUME_ID = 2;
52
+ const COMPACT_ID = 3;
53
+
54
+ const packageVersion = (): string =>
55
+ z.object({ version: z.string().min(1) }).parse(JSON.parse(readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"))).version;
56
+
57
+ export async function compactCodexThread(input: { real: string; threadId: string; env: Record<string, string | undefined> }): Promise<CompactOutcome> {
58
+ const p = Bun.spawn([input.real, "app-server"], {
59
+ env: input.env,
60
+ stdin: "pipe",
61
+ stdout: "pipe",
62
+ stderr: "pipe",
63
+ timeout: CODEX_COMPACT_KILL_MS,
64
+ killSignal: "SIGKILL",
65
+ });
66
+ const send = (msg: Record<string, unknown>): void => {
67
+ p.stdin.write(`${JSON.stringify(msg)}\n`);
68
+ p.stdin.flush();
69
+ };
70
+ const stderrText = new Response(p.stderr).text();
71
+
72
+ const outcome = await new Promise<CompactOutcome>((resolve) => {
73
+ let finished = false;
74
+ const finish = (o: CompactOutcome): void => {
75
+ if (finished) return;
76
+ finished = true;
77
+ resolve(o);
78
+ };
79
+ const onLine = (line: string): void => {
80
+ const parsed = RpcLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
81
+ if (!parsed.success) return;
82
+ const msg = parsed.data;
83
+ if (msg.id === INIT_ID) {
84
+ if (msg.error) return finish({ ok: false, reason: `initialize failed: ${msg.error.message ?? "unknown error"}` });
85
+ send({ method: "initialized" });
86
+ send({ id: RESUME_ID, method: "thread/resume", params: { threadId: input.threadId } });
87
+ return;
88
+ }
89
+ if (msg.id === RESUME_ID) {
90
+ if (msg.error) return finish({ ok: false, reason: `thread/resume failed: ${msg.error.message ?? "unknown error"}` });
91
+ send({ id: COMPACT_ID, method: "thread/compact/start", params: { threadId: input.threadId } });
92
+ return;
93
+ }
94
+ if (msg.id === COMPACT_ID) {
95
+ if (msg.error) return finish({ ok: false, reason: `thread/compact/start failed: ${msg.error.message ?? "unknown error"}` });
96
+ return;
97
+ }
98
+ if (msg.method === "turn/completed") {
99
+ const turn = TurnCompletedSchema.safeParse(msg.params);
100
+ if (!turn.success || turn.data.threadId !== input.threadId) return;
101
+ if (turn.data.turn.status === "completed") return finish({ ok: true });
102
+ return finish({ ok: false, reason: `compaction turn ${turn.data.turn.status}: ${turn.data.turn.error?.message ?? "no error message"}` });
103
+ }
104
+ if (msg.method === "error") {
105
+ const err = ErrorNotificationSchema.safeParse(msg.params);
106
+ if (err.success && err.data.threadId != null && err.data.threadId !== input.threadId) return;
107
+ return finish({ ok: false, reason: `app-server error: ${err.success ? (err.data.error?.message ?? "unknown error") : "unparsable error notification"}` });
108
+ }
109
+ };
110
+ (async () => {
111
+ let buffered = "";
112
+ for await (const chunk of p.stdout) {
113
+ buffered += Buffer.from(chunk).toString("utf8");
114
+ let nl = buffered.indexOf("\n");
115
+ while (nl >= 0) {
116
+ onLine(buffered.slice(0, nl));
117
+ buffered = buffered.slice(nl + 1);
118
+ nl = buffered.indexOf("\n");
119
+ }
120
+ }
121
+ if (buffered.trim() !== "") onLine(buffered);
122
+ const err = (await stderrText).trim().slice(0, 200);
123
+ finish({ ok: false, reason: p.exitCode === null ? `app-server killed after ${CODEX_COMPACT_KILL_MS / 1000}s` : `app-server exited ${p.exitCode}: ${err}` });
124
+ })().catch((e) => finish({ ok: false, reason: e instanceof Error ? e.message : String(e) }));
125
+ send({ id: INIT_ID, method: "initialize", params: { clientInfo: { name: "tokenmaxxing", version: packageVersion() } } });
126
+ });
127
+
128
+ try {
129
+ p.stdin.end();
130
+ } catch {
131
+ }
132
+ p.kill();
133
+ await p.exited;
134
+ log("compact.codex", { thread: input.threadId.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
135
+ return outcome;
136
+ }
@@ -0,0 +1,188 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "./atomic.ts";
5
+ import { grokAuthJsonFor, grokPaths, grokPool, grokSeatFromEnv, grokStoreDirFor } from "./paths.ts";
6
+ import type { Observation, Provider, SampleReport } from "./provider.ts";
7
+ import { loadAccounts, loadConfig, pinBinOverride, type Harvest } from "./state.ts";
8
+ import type { Account } from "./types.ts";
9
+ import { c } from "../cli/render.ts";
10
+
11
+ const GrokAuthEntrySchema = z.looseObject({
12
+ user_id: z.string().optional(),
13
+ principal_id: z.string().optional(),
14
+ email: z.string().nullish(),
15
+ first_name: z.string().nullish(),
16
+ refresh_token: z.string().optional(),
17
+ expires_at: z.string().nullish(),
18
+ });
19
+ type GrokAuthEntry = z.infer<typeof GrokAuthEntrySchema>;
20
+
21
+ function parseGrokAuthFile(path: string): { key: string; entry: GrokAuthEntry }[] {
22
+ let raw: unknown;
23
+ try {
24
+ raw = JSON.parse(readFileSync(path, "utf8"));
25
+ } catch {
26
+ return [];
27
+ }
28
+ const map = z.record(z.string(), z.unknown()).safeParse(raw);
29
+ if (!map.success) return [];
30
+ const out: { key: string; entry: GrokAuthEntry }[] = [];
31
+ for (const [key, value] of Object.entries(map.data)) {
32
+ const parsed = GrokAuthEntrySchema.safeParse(value);
33
+ if (parsed.success) out.push({ key, entry: parsed.data });
34
+ }
35
+ return out;
36
+ }
37
+
38
+ function identityOf(key: string, entry: GrokAuthEntry): { id: string; email: string | null } {
39
+ const id = entry.user_id ?? entry.principal_id ?? key;
40
+ return { id, email: entry.email ?? null };
41
+ }
42
+
43
+ function resolveRealGrok(): string {
44
+ const cfg = loadConfig();
45
+ if (cfg.grokBin) {
46
+ if (!existsSync(cfg.grokBin)) throw new Error(`configured grokBin does not exist: ${cfg.grokBin} - fix config.json`);
47
+ return cfg.grokBin;
48
+ }
49
+ for (const d of (process.env.PATH ?? "").split(":")) {
50
+ if (!d) continue;
51
+ const cand = join(d, "grok");
52
+ try {
53
+ if (existsSync(cand)) return cand;
54
+ } catch {
55
+ continue;
56
+ }
57
+ }
58
+ throw new Error("could not locate the real `grok` binary (set grokBin in config.json)");
59
+ }
60
+
61
+ function liveId(): string | null {
62
+ return grokSeatFromEnv(loadAccounts(grokPool).accounts.map((a) => a.id));
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 --grok`" });
77
+ }
78
+ return reports;
79
+ }
80
+
81
+ async function storeUsable(a: Account): Promise<boolean> {
82
+ try {
83
+ const entries = parseGrokAuthFile(grokAuthJsonFor(a.id));
84
+ return entries.some((e) => identityOf(e.key, e.entry).id === a.id && (e.entry.refresh_token ?? "") !== "");
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function removeCredentials(a: Account): Promise<void> {
91
+ rmSync(grokStoreDirFor(a.id), { recursive: true, force: true });
92
+ }
93
+
94
+ function harvestOf(key: string, entry: GrokAuthEntry): Harvest | null {
95
+ const { id, email } = identityOf(key, entry);
96
+ if (!id) return null;
97
+ return {
98
+ id,
99
+ email,
100
+ tier: null,
101
+ sample: null,
102
+ park: async () => {
103
+ mkdirSync(grokStoreDirFor(id), { recursive: true });
104
+ writeFileAtomic(grokAuthJsonFor(id), JSON.stringify({ [key]: entry }, null, 2), 0o600);
105
+ },
106
+ };
107
+ }
108
+
109
+ async function login(): Promise<Harvest | null> {
110
+ const real = resolveRealGrok();
111
+ const onboardDir = grokPaths.onboardDir;
112
+ rmSync(onboardDir, { recursive: true, force: true });
113
+ mkdirSync(onboardDir, { recursive: true });
114
+ const p = Bun.spawn([real, "login"], {
115
+ stdin: "inherit",
116
+ stdout: "inherit",
117
+ stderr: "inherit",
118
+ env: { ...process.env, GROK_HOME: onboardDir, TOKENMAXXING_PROBE: "1" },
119
+ });
120
+ await p.exited;
121
+ try {
122
+ if (p.exitCode !== 0) {
123
+ console.error(c.red("no grok login landed in the isolated home - nothing added."));
124
+ return null;
125
+ }
126
+ const entries = parseGrokAuthFile(join(onboardDir, "auth.json"));
127
+ if (entries.length === 0) {
128
+ console.error(c.red("no grok login landed in the isolated home - nothing added."));
129
+ return null;
130
+ }
131
+ return harvestOf(entries[0]!.key, entries[0]!.entry);
132
+ } finally {
133
+ rmSync(onboardDir, { recursive: true, force: true });
134
+ }
135
+ }
136
+
137
+ async function importLive(): Promise<Harvest | null> {
138
+ console.log(c.cyan("Opening an isolated grok login for your first pooled account - the login you already have stays as it is."));
139
+ console.log();
140
+ const live = parseGrokAuthFile(join(grokPaths.home, "auth.json"));
141
+ if (live.length > 0) {
142
+ console.log(c.dim(`found ${live.length} grok login(s) in ${grokPaths.home} - pooling the first; use \`tokenmaxxing add --grok\` for the rest.`));
143
+ const h = harvestOf(live[0]!.key, live[0]!.entry);
144
+ if (h) return h;
145
+ }
146
+ return login();
147
+ }
148
+
149
+ function preflight(): void {
150
+ const real = resolveRealGrok();
151
+ const p = Bun.spawnSync([real, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 15_000 });
152
+ const out = (p.stdout?.toString() ?? "").trim().toLowerCase();
153
+ if (p.exitCode !== 0 || !out.includes("grok")) {
154
+ throw new Error(`grok binary failed verification: ${real}`);
155
+ }
156
+ pinBinOverride({ key: "grokBin", bin: real });
157
+ }
158
+
159
+ function install(): void {
160
+ console.log(c.dim("grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts."));
161
+ }
162
+
163
+ export const grok: Provider = {
164
+ name: "grok",
165
+ flag: " --grok",
166
+ pool: grokPool,
167
+ seats: "live",
168
+ waitsWhenDepleted: false,
169
+ statusOnly: true,
170
+ liveId,
171
+ presence,
172
+ gatedFamilies: () => null,
173
+ observeLive,
174
+ samplePool,
175
+ mergeWindows: (next) => next,
176
+ swap: async () => {
177
+ throw new Error("grok moves are not supported yet (status-only pool)");
178
+ },
179
+ classifySwapError: () => "fatal",
180
+ removeCredentials,
181
+ storeUsable,
182
+ login,
183
+ importLive,
184
+ preflight,
185
+ install,
186
+ loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
187
+ windowLabel: (name) => name.toLowerCase(),
188
+ };
@@ -144,7 +144,7 @@ export function installCodexStopHook(): void {
144
144
  ...current.hooks,
145
145
  Stop: [
146
146
  ...withoutOurCodexStopHooks(current.hooks.Stop),
147
- { hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 120, statusMessage: "tokenmaxxing switch check" }] },
147
+ { hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 300, statusMessage: "tokenmaxxing switch check" }] },
148
148
  ],
149
149
  },
150
150
  };
@@ -0,0 +1,186 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { writeFileAtomic } from "./atomic.ts";
6
+ import { opencodeGoAuthJsonFor, opencodeGoPaths, opencodeGoPool, opencodeGoStoreDirFor } from "./paths.ts";
7
+ import type { Observation, Provider, SampleReport } from "./provider.ts";
8
+ import { loadConfig, pinBinOverride, type Harvest } from "./state.ts";
9
+ import type { Account } from "./types.ts";
10
+ import { c } from "../cli/render.ts";
11
+
12
+ const OpencodeAuthEntrySchema = z.looseObject({ type: z.string(), key: z.string().optional() });
13
+ const OpencodeAuthFileSchema = z.record(z.string(), z.unknown());
14
+
15
+ function dataHome(): string {
16
+ return process.env.XDG_DATA_HOME && process.env.XDG_DATA_HOME.length > 0 ? process.env.XDG_DATA_HOME : join(homedir(), ".local", "share");
17
+ }
18
+
19
+ function liveAuthPath(): string {
20
+ return join(dataHome(), "opencode", "auth.json");
21
+ }
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
+ function idOfKey(key: string): string {
38
+ const h = new Bun.CryptoHasher("sha256");
39
+ h.update(`opencode-go:${key}`);
40
+ return `opencode-go-${h.digest("hex")}`;
41
+ }
42
+
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
+ function harvestOf(key: string): Harvest {
95
+ const id = idOfKey(key);
96
+ return {
97
+ id,
98
+ email: null,
99
+ tier: "go",
100
+ sample: null,
101
+ park: async () => {
102
+ mkdirSync(opencodeGoStoreDirFor(id), { recursive: true });
103
+ writeFileAtomic(opencodeGoAuthJsonFor(id), JSON.stringify({ "opencode-go": { type: "api", key } }, null, 2), 0o600);
104
+ },
105
+ };
106
+ }
107
+
108
+ async function login(): Promise<Harvest | null> {
109
+ const real = resolveRealOpencode();
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;
120
+ try {
121
+ if (p.exitCode !== 0) {
122
+ console.error(c.red("no opencode-go login landed in the isolated home - nothing added."));
123
+ return null;
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}`);
153
+ }
154
+ pinBinOverride({ key: "opencodeBin", bin: real });
155
+ }
156
+
157
+ function install(): void {
158
+ console.log(c.dim("opencode-go pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled keys."));
159
+ }
160
+
161
+ export const opencodeGo: Provider = {
162
+ name: "opencode-go",
163
+ flag: " --opencode-go",
164
+ pool: opencodeGoPool,
165
+ seats: "live",
166
+ waitsWhenDepleted: false,
167
+ statusOnly: true,
168
+ liveId,
169
+ presence,
170
+ gatedFamilies: () => null,
171
+ observeLive,
172
+ samplePool,
173
+ mergeWindows: (next) => next,
174
+ swap: async () => {
175
+ throw new Error("opencode-go moves are not supported yet (status-only pool)");
176
+ },
177
+ classifySwapError: () => "fatal",
178
+ removeCredentials,
179
+ storeUsable,
180
+ login,
181
+ importLive,
182
+ preflight,
183
+ install,
184
+ loginStep: () => `Paste the opencode-go API key from ${c.bold("opencode.ai/zen")} when opencode asks for it.`,
185
+ windowLabel: (name) => name.toLowerCase(),
186
+ };
package/src/lib/paths.ts CHANGED
@@ -52,6 +52,18 @@ export const codexPool = {
52
52
  lockFile: join(TM_HOME, "codex-lock"),
53
53
  } as const;
54
54
 
55
+ export const grokPool = {
56
+ accountsJson: join(TM_HOME, "grok-accounts.json"),
57
+ lastSwapJson: null,
58
+ lockFile: join(TM_HOME, "grok-lock"),
59
+ } as const;
60
+
61
+ export const opencodeGoPool = {
62
+ accountsJson: join(TM_HOME, "opencode-go-accounts.json"),
63
+ lastSwapJson: null,
64
+ lockFile: join(TM_HOME, "opencode-go-lock"),
65
+ } as const;
66
+
55
67
  export type PoolPaths = { accountsJson: string; lastSwapJson: string | null; lockFile: string };
56
68
 
57
69
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
@@ -71,6 +83,41 @@ export function codexCredItemFor(accountId: string): string {
71
83
  return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
72
84
  }
73
85
 
86
+ const GROK_HOME_DEFAULT = join(HOME, ".grok");
87
+
88
+ export const grokPaths = {
89
+ home: env("GROK_HOME", GROK_HOME_DEFAULT),
90
+ storesDir: join(TM_HOME, "grok-stores"),
91
+ onboardDir: join(TM_HOME, "grok-onboard"),
92
+ } as const;
93
+
94
+ export function grokStoreDirFor(accountId: string): string {
95
+ return join(grokPaths.storesDir, shortId(accountId));
96
+ }
97
+
98
+ export function grokAuthJsonFor(accountId: string): string {
99
+ return join(grokStoreDirFor(accountId), "auth.json");
100
+ }
101
+
102
+ export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
103
+ const home = env.GROK_HOME;
104
+ if (home == null || home === "") return null;
105
+ return accountIds.find((id) => grokStoreDirFor(id) === home) ?? null;
106
+ }
107
+
108
+ export const opencodeGoPaths = {
109
+ storesDir: join(TM_HOME, "opencode-go-stores"),
110
+ onboardDir: join(TM_HOME, "opencode-go-onboard"),
111
+ } as const;
112
+
113
+ export function opencodeGoStoreDirFor(accountId: string): string {
114
+ return join(opencodeGoPaths.storesDir, shortId(accountId));
115
+ }
116
+
117
+ export function opencodeGoAuthJsonFor(accountId: string): string {
118
+ return join(opencodeGoStoreDirFor(accountId), "auth.json");
119
+ }
120
+
74
121
  export const keychain = {
75
122
  account: env("TOKENMAXXING_KEYCHAIN_ACCOUNT", process.env.USER ?? "unknown"),
76
123
  } as const;
@@ -111,4 +158,12 @@ export function realCodexBinFromEnv(): string | undefined {
111
158
  return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
112
159
  }
113
160
 
161
+ export function realGrokBinFromEnv(): string | undefined {
162
+ return EnvOverrideSchema.parse(process.env.TOKENMAXXING_GROK_BIN);
163
+ }
164
+
165
+ export function realOpencodeBinFromEnv(): string | undefined {
166
+ return EnvOverrideSchema.parse(process.env.TOKENMAXXING_OPENCODE_BIN);
167
+ }
168
+
114
169
  export { HOME };
@@ -9,11 +9,12 @@ export type SampleReport = { ok: true; source: "statusline" | "probe" } | { ok:
9
9
  export type SwapFailure = "dead-grant" | "skip" | "fatal";
10
10
 
11
11
  export type Provider = {
12
- name: "claude" | "codex";
12
+ name: "claude" | "codex" | "grok" | "opencode-go";
13
13
  flag: string;
14
14
  pool: PoolPaths;
15
15
  seats: "shared" | "live";
16
16
  waitsWhenDepleted: boolean;
17
+ statusOnly: boolean;
17
18
  liveId(): string | null;
18
19
  presence(): Map<string, number>;
19
20
  gatedFamilies(cfg: Config): string[] | null;
@@ -48,13 +48,14 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
48
48
  return SupervisedSessionSchema.parse({ sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null });
49
49
  }
50
50
 
51
- export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number }): void {
51
+ export function writeRespawnMarker(input: { session: SupervisedSession; sessionId: string; accountId: string; waitUntil: number; compact: boolean }): void {
52
52
  mkdirSync(paths.respawnDir, { recursive: true });
53
53
  const payload = RespawnMarkerSchema.parse({
54
54
  accountId: input.accountId,
55
55
  ts: Date.now(),
56
56
  waitUntil: input.waitUntil,
57
57
  sessionId: input.sessionId,
58
+ compact: input.compact,
58
59
  ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
59
60
  });
60
61
  writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
package/src/lib/state.ts CHANGED
@@ -1,7 +1,7 @@
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 { paths, realClaudeBinFromEnv, realCodexBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
4
+ import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, realGrokBinFromEnv, realOpencodeBinFromEnv, usageJsonFor, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
@@ -35,10 +35,14 @@ export function loadConfig(): Config {
35
35
  if (envBin) cfg.claudeBin = envBin;
36
36
  const envCodexBin = realCodexBinFromEnv();
37
37
  if (envCodexBin) cfg.codexBin = envCodexBin;
38
+ const envGrokBin = realGrokBinFromEnv();
39
+ if (envGrokBin) cfg.grokBin = envGrokBin;
40
+ const envOpencodeBin = realOpencodeBinFromEnv();
41
+ if (envOpencodeBin) cfg.opencodeBin = envOpencodeBin;
38
42
  return cfg;
39
43
  }
40
44
 
41
- export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
45
+ export function pinBinOverride(input: { key: "claudeBin" | "codexBin" | "grokBin" | "opencodeBin"; bin: string }): void {
42
46
  let raw: Record<string, unknown> = {};
43
47
  if (existsSync(paths.configJson)) {
44
48
  raw = z.record(z.string(), z.unknown()).parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
package/src/lib/types.ts CHANGED
@@ -110,6 +110,8 @@ export const ConfigSchema = z
110
110
  .prefault({}),
111
111
  claudeBin: z.string().default(""),
112
112
  codexBin: z.string().default(""),
113
+ grokBin: z.string().default(""),
114
+ opencodeBin: z.string().default(""),
113
115
  policy: z
114
116
  .object({
115
117
  projectionMargin: z.number().min(0).max(100).default(0),
@@ -134,6 +136,7 @@ export const RespawnMarkerSchema = z.object({
134
136
  ts: z.number(),
135
137
  waitUntil: z.number(),
136
138
  sessionId: z.string(),
139
+ compact: z.boolean(),
137
140
  launchedAt: z.number().optional(),
138
141
  });
139
142
 
package/src/main.ts CHANGED
@@ -11,6 +11,8 @@ import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
11
11
  import { runCodexStopHook } from "./entries/codexstophook.ts";
12
12
  import { claude } from "./lib/claude.ts";
13
13
  import { codex } from "./lib/codex.ts";
14
+ import { grok } from "./lib/grok.ts";
15
+ import { opencodeGo } from "./lib/opencodego.ts";
14
16
  import { cmdInit } from "./cli/init.ts";
15
17
  import { cmdAdd } from "./cli/add.ts";
16
18
  import { cmdAuth } from "./cli/auth.ts";
@@ -30,8 +32,11 @@ import { c, emitError } from "./cli/render.ts";
30
32
  const JSON_FLAG = "--json";
31
33
  const CACHED_FLAG = "--cached";
32
34
  const CODEX_FLAG = "--codex";
35
+ const GROK_FLAG = "--grok";
36
+ const OPENCODE_GO_FLAG = "--opencode-go";
33
37
  const JSON_COMMANDS = new Set(["status", "config", "check", "switch"]);
34
38
  const CODEX_COMMANDS = new Set(["init", "add", "auth", "switch", "rm", "rename"]);
39
+ const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
35
40
 
36
41
  function printHelp(): void {
37
42
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
@@ -40,15 +45,17 @@ function printHelp(): void {
40
45
  ${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
41
46
  ${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
42
47
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
48
+ ${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
49
+ ${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
43
50
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
44
51
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
45
- ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
52
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --opencode-go] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
46
53
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
47
54
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
48
55
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
49
56
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
50
- ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
51
- ${c.cyan("tokenmaxxing rm")} [--codex] <sel>
57
+ ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --opencode-go] <sel> <label>
58
+ ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --opencode-go] <sel>
52
59
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
53
60
  ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
54
61
  ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
@@ -79,8 +86,13 @@ async function main(): Promise<number> {
79
86
  jsonMode = argv.includes(JSON_FLAG);
80
87
  const json = jsonMode;
81
88
  const cached = argv.includes(CACHED_FLAG);
82
- const provider = argv.includes(CODEX_FLAG) ? codex : claude;
83
- const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG);
89
+ const providerFlags = [CODEX_FLAG, GROK_FLAG, OPENCODE_GO_FLAG].filter((f) => argv.includes(f));
90
+ if (providerFlags.length > 1) {
91
+ emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
92
+ return 2;
93
+ }
94
+ const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(OPENCODE_GO_FLAG) ? opencodeGo : claude;
95
+ const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== OPENCODE_GO_FLAG);
84
96
  const sub = args[0];
85
97
 
86
98
  if (cached && sub != null && sub !== "status") {
@@ -93,6 +105,12 @@ async function main(): Promise<number> {
93
105
  return 2;
94
106
  }
95
107
 
108
+ if ((provider === grok || provider === opencodeGo) && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
109
+ const flag = provider === grok ? GROK_FLAG : OPENCODE_GO_FLAG;
110
+ emitError({ json, message: `${flag} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
111
+ return 2;
112
+ }
113
+
96
114
  if (json && sub != null && !JSON_COMMANDS.has(sub)) {
97
115
  emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
98
116
  return 2;