tokenmaxxing 1.42.0 → 1.43.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/package.json +1 -1
- package/src/entries/codexstophook.ts +21 -1
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/stopfailurehook.ts +1 -1
- package/src/entries/stophook.ts +1 -1
- package/src/entries/supervisor.ts +26 -8
- package/src/lib/compact.ts +136 -0
- package/src/lib/install.ts +1 -1
- package/src/lib/sessions.ts +2 -1
- package/src/lib/types.ts +1 -0
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
|
|
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.
|
|
3
|
+
"version": "1.43.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",
|
|
@@ -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 });
|
package/src/entries/stophook.ts
CHANGED
|
@@ -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
|
|
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
|
|
284
|
-
log("supervisor.launch", { sid, respawns, seat:
|
|
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
|
-
...(
|
|
302
|
+
...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
|
|
295
303
|
},
|
|
296
304
|
});
|
|
297
|
-
if (
|
|
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`);
|
|
@@ -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
|
+
}
|
package/src/lib/install.ts
CHANGED
|
@@ -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:
|
|
147
|
+
{ hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 300, statusMessage: "tokenmaxxing switch check" }] },
|
|
148
148
|
],
|
|
149
149
|
},
|
|
150
150
|
};
|
package/src/lib/sessions.ts
CHANGED
|
@@ -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));
|