tokenmaxxing 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -2
- package/README.md +1 -1
- package/agent-plugin/plugin.json +8 -2
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +11 -42
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +8 -34
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -63
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -23
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +57 -124
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -45
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -35
- package/src/sdk.ts +0 -59
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
// Native statusLine. Reads Claude's statusLine stdin, tees the rate-limit data
|
|
2
|
-
// to usage.json (write-on-change, O(ms)), then renders ONE line:
|
|
3
|
-
// worktree name (linked worktrees only), model name painted by context fill
|
|
4
|
-
// (effort in parens), +added/-removed, then quota as COLOR (user decisions
|
|
5
|
-
// 2026-07-18): each window is its time-to-reset painted on a continuous
|
|
6
|
-
// green->yellow->red ramp by used% - "◆ 𝒇 2h 1d ◇ 2d ◇ full" - the
|
|
7
|
-
// active account's windows after a green ◆ (per-model by bare initial
|
|
8
|
-
// first, fable as 𝒇, other families uppercased; "𝒇?" unpainted when the
|
|
9
|
-
// cap applies but is unmeasured, then session/5h, then week), then EVERY
|
|
10
|
-
// parked account's week after its own cyan ◇ (red ✗ if needs-reauth),
|
|
11
|
-
// sorted by earliest upcoming reset (needs-reauth last). A window with no
|
|
12
|
-
// upcoming reset renders "0" (empty again); measured usage with an unknown
|
|
13
|
-
// reset clock renders a painted "?". With color disabled the numeric format
|
|
14
|
-
// returns ("2h5" = resets in 2h, 5 used; "ctx 42"): without color a drained
|
|
15
|
-
// window must not look fresh. Blocks are joined by TWO spaces, tokens
|
|
16
|
-
// within a block by one. Per-model resets are omitted (they match the
|
|
17
|
-
// weekly reset).
|
|
18
|
-
// Must NEVER break the status line: render what parses, skip what doesn't.
|
|
19
|
-
|
|
20
1
|
import { sortBy } from "es-toolkit";
|
|
21
2
|
import { z } from "zod";
|
|
22
3
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -36,27 +17,16 @@ import {
|
|
|
36
17
|
type UsageWindow,
|
|
37
18
|
} from "../lib/types.ts";
|
|
38
19
|
|
|
39
|
-
/** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
|
|
40
|
-
* the OLD account's windows while ~/.claude.json already names the NEW org.
|
|
41
|
-
* Suppress the tee for this long after a swap so that mislabel never lands. */
|
|
42
20
|
const ADOPTION_GRACE_MS = 45_000;
|
|
43
21
|
|
|
44
22
|
const RenderCtxSchema = z.object({
|
|
45
23
|
accounts: AccountsIndexSchema,
|
|
46
|
-
/** active account's per-model weekly windows ({} unless fresh for the live org). */
|
|
47
24
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
48
|
-
/** families (lowercased) whose per-model weekly cap gates a switch. */
|
|
49
25
|
switchModels: z.array(z.string()),
|
|
50
|
-
/** linked-worktree basename, null in a main checkout. */
|
|
51
26
|
worktree: z.string().nullable(),
|
|
52
|
-
/** the LIVE login's org from claude.json, the seat's identity - the
|
|
53
|
-
* activeAccountUuid label drifts after a manual /login (the same rule as
|
|
54
|
-
* decide.ts's seatOf; closing-review catch: the label-keyed split rendered
|
|
55
|
-
* the live account twice and hid the stale-labeled one). */
|
|
56
27
|
liveOrg: z.string().nullable(),
|
|
57
28
|
now: z.number(),
|
|
58
29
|
color: z.boolean(),
|
|
59
|
-
/** terminal advertises 24-bit color (COLORTERM); false steps the ramp to the 256-color cube. */
|
|
60
30
|
truecolor: z.boolean(),
|
|
61
31
|
});
|
|
62
32
|
export type RenderCtx = z.infer<typeof RenderCtxSchema>;
|
|
@@ -67,22 +37,17 @@ export async function readStdin(): Promise<string> {
|
|
|
67
37
|
return Buffer.concat(chunks).toString("utf8");
|
|
68
38
|
}
|
|
69
39
|
|
|
70
|
-
/** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
|
|
71
40
|
export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
72
41
|
const col = makeColors(ctx.color);
|
|
73
42
|
const paint = makeUsagePaint({ enabled: ctx.color, truecolor: ctx.truecolor });
|
|
74
|
-
// Used quota in a window; one whose reset has passed is empty again.
|
|
75
43
|
const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
|
|
76
44
|
const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
|
|
77
45
|
|
|
78
46
|
const parsed = StatusLineStdinSchema.safeParse(stdinObj);
|
|
79
47
|
const d = parsed.success ? parsed.data : null;
|
|
80
48
|
|
|
81
|
-
// ---- info block: worktree, model, ctx, diff
|
|
82
49
|
const info: string[] = [];
|
|
83
50
|
if (ctx.worktree != null) info.push(ctx.worktree);
|
|
84
|
-
// The model name carries the context-fill color (user rule 2026-07-18, no
|
|
85
|
-
// ctx token); colorless mode keeps the numeric ctx token instead.
|
|
86
51
|
const modelName = d?.model?.display_name ?? d?.model?.id;
|
|
87
52
|
const ctxUsed = d?.context_window?.used_percentage;
|
|
88
53
|
if (modelName) {
|
|
@@ -93,26 +58,17 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
93
58
|
if (!ctx.color && ctxUsed != null) info.push(`ctx ${Math.round(ctxUsed)}`);
|
|
94
59
|
const added = d?.cost?.total_lines_added ?? 0;
|
|
95
60
|
const removed = d?.cost?.total_lines_removed ?? 0;
|
|
96
|
-
// -removed stays unpainted: red means quota alarm and nothing else.
|
|
97
61
|
if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
|
|
98
62
|
|
|
99
|
-
// ---- active account block
|
|
100
|
-
// A window token: color carries the used%, the text is the reset countdown
|
|
101
|
-
// (or the bare per-model initial - per-model resets are omitted). "0" = no
|
|
102
|
-
// upcoming reset (the window is empty again); a painted "?" = measured usage
|
|
103
|
-
// whose reset clock is unknown. Colorless mode glues the number back on.
|
|
104
63
|
const seg = (label: string, w: UsageWindow, resetAt: number | null) => {
|
|
105
64
|
const u = used(w);
|
|
106
65
|
if (!ctx.color) return `${label}${reset(resetAt)}${Math.round(u)}`;
|
|
107
66
|
const body = label !== "" ? label : reset(resetAt);
|
|
108
67
|
return col.bold(paint(u)(body !== "" ? body : u > 0 ? "?" : "0"));
|
|
109
68
|
};
|
|
110
|
-
// Per-model initial: fable renders 𝒇 (user rule 2026-07-18), family-matched
|
|
111
|
-
// as always; other families keep their uppercased first letter.
|
|
112
69
|
const initial = (name: string) => (familyTokens(name).includes("fable") ? "𝒇" : name.slice(0, 1).toUpperCase());
|
|
113
70
|
const wins = parseStatusLineStdin(stdinObj);
|
|
114
71
|
const windows: string[] = [];
|
|
115
|
-
// A capacity-constrained model whose cap is unmeasured must not look safe.
|
|
116
72
|
const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
|
|
117
73
|
if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
|
|
118
74
|
windows.push(`${initial(family)}?`);
|
|
@@ -122,7 +78,6 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
122
78
|
windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
|
|
123
79
|
windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
|
|
124
80
|
}
|
|
125
|
-
// The seat: live-org first, stored label fallback (unknown live identity).
|
|
126
81
|
const seatUuid =
|
|
127
82
|
(ctx.liveOrg != null ? ctx.accounts.accounts.find((a) => a.organizationUuid === ctx.liveOrg)?.accountUuid : undefined) ??
|
|
128
83
|
ctx.accounts.activeAccountUuid;
|
|
@@ -133,13 +88,10 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
133
88
|
? `${col.green("◆")} ?`
|
|
134
89
|
: "";
|
|
135
90
|
|
|
136
|
-
// ---- parked accounts, earliest upcoming reset first (needs-reauth last)
|
|
137
91
|
const parked = sortBy(
|
|
138
92
|
ctx.accounts.accounts.filter((a) => a.accountUuid !== seatUuid),
|
|
139
93
|
[(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
|
|
140
94
|
);
|
|
141
|
-
// Every parked account renders its own marker (user rule 2026-07-18: the
|
|
142
|
-
// old counted collapse "◇ 3 full" hid the pool size and read as confusing).
|
|
143
95
|
const poolSeg = (a: Account): string => {
|
|
144
96
|
const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
|
|
145
97
|
const week = a.lastUsage?.sevenDay;
|
|
@@ -148,7 +100,6 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
148
100
|
if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
|
|
149
101
|
|
|
150
102
|
const parts: string[] = [];
|
|
151
|
-
// A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
|
|
152
103
|
for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
|
|
153
104
|
if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
|
|
154
105
|
}
|
|
@@ -166,25 +117,11 @@ export async function runStatusline(): Promise<number> {
|
|
|
166
117
|
try {
|
|
167
118
|
obj = JSON.parse(raw);
|
|
168
119
|
} catch {
|
|
169
|
-
// malformed stdin - render from state alone
|
|
170
120
|
}
|
|
171
121
|
const now = Date.now();
|
|
172
122
|
|
|
173
|
-
// The stdin payload's own org label: those rate_limits and that
|
|
174
|
-
// organizationUuid ride the SAME API response, so the label can never lie
|
|
175
|
-
// about whose windows these are. claude.json's org is only the fallback -
|
|
176
|
-
// it flips at the swap while a session that has made no post-swap request
|
|
177
|
-
// keeps rendering the OLD account's windows indefinitely, and the 45s
|
|
178
|
-
// ADOPTION_GRACE_MS bounds nothing for such a session (closing-review
|
|
179
|
-
// catch: a stale-window tee labeled with the new org could hard-swap a
|
|
180
|
-
// healthy account and stamp foreign usage into it). The render below uses
|
|
181
|
-
// the same preference so the active ◆ seat matches the windows painted
|
|
182
|
-
// beside it (PR #36 review catch). Trusted ONLY when the payload's windows
|
|
183
|
-
// parsed too: an org label without windows would re-label state the payload
|
|
184
|
-
// did not carry (second-round catch).
|
|
185
123
|
let stdinOrg: string | null = null;
|
|
186
124
|
|
|
187
|
-
// tee usage for the Stop hook / status - best effort, never blocks rendering.
|
|
188
125
|
let org: string | null = null;
|
|
189
126
|
try {
|
|
190
127
|
org = readOAuthAccount()?.organizationUuid ?? null;
|
|
@@ -197,7 +134,6 @@ export async function runStatusline(): Promise<number> {
|
|
|
197
134
|
writeUsage(state);
|
|
198
135
|
}
|
|
199
136
|
} catch {
|
|
200
|
-
// skip the tee, still render below
|
|
201
137
|
}
|
|
202
138
|
|
|
203
139
|
let line: string;
|
|
@@ -219,8 +155,6 @@ export async function runStatusline(): Promise<number> {
|
|
|
219
155
|
};
|
|
220
156
|
line = renderStatusline(obj, ctx);
|
|
221
157
|
} catch (e) {
|
|
222
|
-
// Corrupt local state (the loaders throw on it) must stay VISIBLE: render
|
|
223
|
-
// the failure as the statusline itself, never abort into a blank line.
|
|
224
158
|
line = `tokenmaxxing: ${e instanceof Error ? e.message : String(e)}`;
|
|
225
159
|
}
|
|
226
160
|
process.stdout.write(line + "\n");
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { paths } from "../lib/paths.ts";
|
|
4
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
|
+
import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
|
|
7
|
+
import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
|
|
8
|
+
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
9
|
+
import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
10
|
+
import { log } from "../lib/log.ts";
|
|
11
|
+
|
|
12
|
+
export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
|
|
13
|
+
|
|
14
|
+
const StopFailureStdin = z.looseObject({
|
|
15
|
+
session_id: z.uuid().optional().catch(undefined),
|
|
16
|
+
transcript_path: z.string().optional().catch(undefined),
|
|
17
|
+
error: z.string().optional().catch(undefined),
|
|
18
|
+
agent_id: z.string().optional().catch(undefined),
|
|
19
|
+
last_assistant_message: z.string().optional().catch(undefined),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
|
|
23
|
+
|
|
24
|
+
async function readStdin(): Promise<string> {
|
|
25
|
+
const chunks: Uint8Array[] = [];
|
|
26
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
27
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function runStopFailureHook(): Promise<number> {
|
|
31
|
+
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
32
|
+
|
|
33
|
+
const org = readOAuthAccount()?.organizationUuid ?? null;
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const raw = await readStdin();
|
|
36
|
+
const parsed = StopFailureStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
37
|
+
const stdin = parsed.success ? parsed.data : {};
|
|
38
|
+
if (stdin.error !== undefined && stdin.error !== "rate_limit") return 0;
|
|
39
|
+
|
|
40
|
+
const stdinSid = stdin.session_id;
|
|
41
|
+
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
42
|
+
const launchedAt = LaunchedAtSchema.parse(process.env.TOKENMAXXING_LAUNCHED_AT) ?? null;
|
|
43
|
+
const mainLoop = stdin.agent_id === undefined;
|
|
44
|
+
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null && mainLoop;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const cfg = loadConfig();
|
|
48
|
+
const found = stdin.transcript_path
|
|
49
|
+
? findEnforcedRow({ rows: readTranscriptTail(stdin.transcript_path), lastAssistantMessage: stdin.last_assistant_message, now })
|
|
50
|
+
: null;
|
|
51
|
+
const limit = found ? classifyEnforcedLimit(found.row, cfg.policy.switchModels) : null;
|
|
52
|
+
|
|
53
|
+
let enforced: EnforcedLimit | null = null;
|
|
54
|
+
if (limit && found && org) {
|
|
55
|
+
if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
56
|
+
const stamp = await recordEnforcedLimit({ limit, org, now });
|
|
57
|
+
log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
|
|
58
|
+
if (stamp.outcome !== "org-moved") {
|
|
59
|
+
enforced = { org, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
log("stopfailure.unproven", { kind: limit.kind });
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
log("stopfailure.unclassified", {
|
|
66
|
+
row: found != null,
|
|
67
|
+
type: found?.row.quotaLimits?.rateLimitType,
|
|
68
|
+
transient: found?.row.apiErrorIsTransient,
|
|
69
|
+
body: found ? parseErrorBody(found.row.errorDetails)?.error?.type : undefined,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
|
|
74
|
+
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
75
|
+
const marker = join(paths.respawnDir, pinnedSid);
|
|
76
|
+
const payload = RespawnMarkerSchema.parse({
|
|
77
|
+
account: decision.account.label,
|
|
78
|
+
ts: Date.now(),
|
|
79
|
+
waitUntil: decision.waitUntil ?? now,
|
|
80
|
+
sessionId: stdinSid ?? pinnedSid,
|
|
81
|
+
prompt: RETRIGGER_PROMPT,
|
|
82
|
+
...(launchedAt != null ? { launchedAt } : {}),
|
|
83
|
+
});
|
|
84
|
+
writeFileAtomic(marker, JSON.stringify(payload));
|
|
85
|
+
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
|
|
86
|
+
} else {
|
|
87
|
+
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
88
|
+
}
|
|
89
|
+
} catch (e) {
|
|
90
|
+
log("stopfailure.error", { err: e instanceof Error ? e.message : String(e) });
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// Stop hook. Fires when claude finishes a turn (transcript already committed).
|
|
2
|
-
// A plain swap needs no respawn: the running session adopts the swapped
|
|
3
|
-
// credential on its own (<=30s on macOS, next request on Linux). Only a
|
|
4
|
-
// depleted-pool wait - when running under the supervisor - drops a respawn
|
|
5
|
-
// marker keyed by this session id: the supervisor SIGTERMs its child at this
|
|
6
|
-
// clean boundary, counts down to the reset, then relaunches `--resume`. We
|
|
7
|
-
// never kill claude ourselves.
|
|
8
|
-
|
|
9
1
|
import { join } from "node:path";
|
|
10
2
|
import { z } from "zod";
|
|
11
3
|
import { paths } from "../lib/paths.ts";
|
|
@@ -14,10 +6,6 @@ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
|
14
6
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
15
7
|
import { log } from "../lib/log.ts";
|
|
16
8
|
|
|
17
|
-
// session_id must be a real transcript UUID: a malformed value would ride the
|
|
18
|
-
// respawn marker into `--resume <garbage>`, which claude treats as a picker
|
|
19
|
-
// search term (PR #36 review catch); non-UUID input drops to undefined and the
|
|
20
|
-
// marker falls back to the pinned sid.
|
|
21
9
|
const StopStdin = z.looseObject({ session_id: z.uuid().optional().catch(undefined) });
|
|
22
10
|
|
|
23
11
|
async function readStdin(): Promise<string> {
|
|
@@ -27,35 +15,27 @@ async function readStdin(): Promise<string> {
|
|
|
27
15
|
}
|
|
28
16
|
|
|
29
17
|
export async function runStopHook(): Promise<number> {
|
|
30
|
-
// recursion guard - our own `-p '/usage'` probe re-enters hooks.
|
|
31
18
|
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
32
19
|
|
|
33
20
|
const raw = await readStdin();
|
|
34
21
|
const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
35
|
-
// TWO session ids with different jobs (closing-review HIGH catch): the
|
|
36
|
-
// PINNED id (env, set once by the supervisor) names the marker file the
|
|
37
|
-
// supervisor actually watches and survives /clear; the STDIN id names the
|
|
38
|
-
// CURRENT transcript to resume and drifts to a new value after /clear.
|
|
39
|
-
// Keying the file by the stdin id orphaned every post-/clear marker.
|
|
40
22
|
const stdinSid = parsed.success ? parsed.data.session_id : undefined;
|
|
41
23
|
const pinnedSid = process.env.TOKENMAXXING_SESSION_ID;
|
|
42
24
|
|
|
43
25
|
try {
|
|
44
|
-
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
45
|
-
// will actually pause the session until the reset.
|
|
46
26
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
47
27
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
48
28
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
49
29
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
50
|
-
// Respawn only for a depleted-pool wait: pausing until the reset requires
|
|
51
|
-
// killing the child. A plain swap leaves the session running to adopt.
|
|
52
30
|
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
53
31
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
32
|
+
const launchedAt = z.coerce.number().finite().optional().catch(undefined).parse(process.env.TOKENMAXXING_LAUNCHED_AT);
|
|
54
33
|
const payload = RespawnMarkerSchema.parse({
|
|
55
34
|
account: decision.account.label,
|
|
56
35
|
ts: Date.now(),
|
|
57
36
|
waitUntil: decision.waitUntil,
|
|
58
37
|
sessionId: stdinSid ?? pinnedSid,
|
|
38
|
+
...(launchedAt != null ? { launchedAt } : {}),
|
|
59
39
|
});
|
|
60
40
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
61
41
|
log("stop.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8) });
|
|
@@ -64,5 +44,5 @@ export async function runStopHook(): Promise<number> {
|
|
|
64
44
|
} catch (e) {
|
|
65
45
|
log("stop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
66
46
|
}
|
|
67
|
-
return 0;
|
|
47
|
+
return 0;
|
|
68
48
|
}
|
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
// Renders claude's subagentStatusLine: one {id, content} JSON line per active
|
|
2
|
-
// subagent task, so the agents panel shows each subagent's model, effort, and
|
|
3
|
-
// ctx fill the way the main statusline shows the session's (user ask
|
|
4
|
-
// 2026-07-18). This per-row panel surface is the only place subagent info can
|
|
5
|
-
// appear: the main statusLine command never learns which subagent the UI is
|
|
6
|
-
// viewing (verified against the 2.1.214 bundle). Row shape mirrors the main
|
|
7
|
-
// info block, info first so claude's end-truncation eats the label instead:
|
|
8
|
-
// "fable (high) <task label>" - the family name painted by the task's
|
|
9
|
-
// context fill (colorless mode keeps a numeric ctx token).
|
|
10
|
-
// A task we emit nothing for keeps claude's default row (emitting empty
|
|
11
|
-
// content would HIDE it). Must NEVER break the panel: render what parses,
|
|
12
|
-
// skip what doesn't.
|
|
13
|
-
|
|
14
1
|
import { z } from "zod";
|
|
15
2
|
import { makeColors, makeUsagePaint } from "../cli/render.ts";
|
|
16
3
|
import { readStdin } from "./statusline.ts";
|
|
@@ -19,14 +6,11 @@ import { SubagentStatusLineStdinSchema } from "../lib/types.ts";
|
|
|
19
6
|
const RowCtxSchema = z.object({ color: z.boolean(), truecolor: z.boolean() });
|
|
20
7
|
export type RowCtx = z.infer<typeof RowCtxSchema>;
|
|
21
8
|
|
|
22
|
-
/** "claude-fable-5" -> "fable" (lowercase family, matching the chart-label
|
|
23
|
-
* convention); an id without the claude- prefix passes through whole. */
|
|
24
9
|
function modelFamily(id: string): string {
|
|
25
10
|
const [head, family] = id.split("-");
|
|
26
11
|
return head === "claude" && family ? family : id;
|
|
27
12
|
}
|
|
28
13
|
|
|
29
|
-
/** Pure renderer: subagentStatusLine stdin -> {id, content} JSON lines. */
|
|
30
14
|
export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
|
|
31
15
|
const parsed = SubagentStatusLineStdinSchema.safeParse(stdinObj);
|
|
32
16
|
if (!parsed.success) return [];
|
|
@@ -41,8 +25,6 @@ export function renderSubagentRows(stdinObj: unknown, ctx: RowCtx): string[] {
|
|
|
41
25
|
? Math.round((t.tokenCount / t.contextWindowSize) * 100)
|
|
42
26
|
: null;
|
|
43
27
|
const info: string[] = [];
|
|
44
|
-
// The model name carries the task's context-fill color (same rule as the
|
|
45
|
-
// main line); colorless mode keeps the numeric ctx token instead.
|
|
46
28
|
if (t.model != null) {
|
|
47
29
|
const family = modelFamily(t.model);
|
|
48
30
|
const body = pct != null ? col.bold(paint(pct)(family)) : col.bold(family);
|
|
@@ -66,7 +48,6 @@ export async function runSubagentStatusline(): Promise<number> {
|
|
|
66
48
|
try {
|
|
67
49
|
obj = JSON.parse(raw);
|
|
68
50
|
} catch {
|
|
69
|
-
// malformed stdin - emit nothing, claude keeps its default rows
|
|
70
51
|
}
|
|
71
52
|
const colorterm = z.string().optional().parse(process.env.COLORTERM);
|
|
72
53
|
const rows = renderSubagentRows(obj, {
|