tokenmaxxing 1.8.0 → 1.9.1
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 -4
- package/README.md +1 -1
- package/agent-plugin/plugin.json +1 -1
- 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 +0 -40
- 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 +1 -33
- 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 -73
- 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 -34
- 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 +4 -29
- 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 +6 -127
- 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 -48
- 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 -40
- package/src/sdk.ts +0 -59
- package/agent-plugin/agents/tokenmaxxing-claude.md +0 -43
- package/agent-plugin/agents/tokenmaxxing-codex.md +0 -40
- package/agent-plugin/hooks/cursor-relay.json +0 -14
- package/agent-plugin/skills/relay-session/SKILL.md +0 -118
- package/agent-plugin/skills/relay-session/references/ipc.md +0 -23
- package/src/cli/relay.ts +0 -323
- package/src/entries/relaypermission.ts +0 -105
- package/src/lib/relay/config.ts +0 -84
- package/src/lib/relay/decide.ts +0 -75
- package/src/lib/relay/gc.ts +0 -80
- package/src/lib/relay/install.ts +0 -143
- package/src/lib/relay/markers.ts +0 -148
- package/src/lib/relay/modes.ts +0 -82
- package/src/lib/relay/protocol.ts +0 -61
- package/src/lib/relay/registry.ts +0 -175
- package/src/lib/relay/tmux.ts +0 -109
- package/src/lib/relay/turn.ts +0 -137
- package/src/lib/relay/worker.ts +0 -141
package/src/lib/usage.ts
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
// windows only, epoch resets) and `claude -p '/usage'` (free, 0 tokens, all
|
|
3
|
-
// three limit kinds). `/usage` is a client-side command that renders the same
|
|
4
|
-
// figures claude's own usage screen shows; we run it in a throwaway
|
|
5
|
-
// CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
|
|
6
|
-
|
|
7
|
-
import { mkdirSync } from "node:fs";
|
|
1
|
+
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
8
2
|
import { join } from "node:path";
|
|
9
3
|
import { delay } from "es-toolkit";
|
|
10
4
|
import { z } from "zod";
|
|
11
5
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
12
|
-
import { readOAuthAccount } from "./claudejson.ts";
|
|
13
|
-
import { withLock } from "./lock.ts";
|
|
14
6
|
import { log } from "./log.ts";
|
|
15
7
|
import { paths } from "./paths.ts";
|
|
16
|
-
import { loadUsage, writeUsage } from "./state.ts";
|
|
17
8
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
18
9
|
|
|
19
|
-
/** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
|
|
20
10
|
export function normalizeResetsAt(v: unknown): number | null {
|
|
21
11
|
const num = z.number().finite().safeParse(v);
|
|
22
12
|
if (num.success) {
|
|
@@ -37,9 +27,6 @@ const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWi
|
|
|
37
27
|
resetsAt: normalizeResetsAt(w.resets_at),
|
|
38
28
|
});
|
|
39
29
|
|
|
40
|
-
/** Extract the two AGGREGATE windows from statusLine stdin. null if absent.
|
|
41
|
-
* All-or-nothing on the aggregates (mirrors the /usage parser): fabricating
|
|
42
|
-
* 0% for a missing window would make unmeasured read as safe. */
|
|
43
30
|
export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
|
|
44
31
|
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
45
32
|
if (!parsed.success) return null;
|
|
@@ -48,7 +35,6 @@ export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
|
|
|
48
35
|
return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
|
|
49
36
|
}
|
|
50
37
|
|
|
51
|
-
/** Extract the active model from statusLine stdin. null if absent. */
|
|
52
38
|
export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
53
39
|
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
54
40
|
if (!parsed.success) return null;
|
|
@@ -57,31 +43,16 @@ export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
|
57
43
|
return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
|
|
58
44
|
}
|
|
59
45
|
|
|
60
|
-
/** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
|
|
61
|
-
* "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
|
|
62
|
-
* drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
|
|
63
|
-
* historically flipped between family-first and version-first), so gates match
|
|
64
|
-
* a family token anywhere instead of an exact string - an exact-string gate
|
|
65
|
-
* silently disabled the per-model check in the 2026-07-09/10 incidents. */
|
|
66
46
|
export function familyTokens(s: string): string[] {
|
|
67
47
|
return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
|
|
68
48
|
}
|
|
69
49
|
|
|
70
|
-
/** The switchModels family the active model belongs to, from its id OR display
|
|
71
|
-
* tokens; null when the model is not capacity-constrained. */
|
|
72
50
|
export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
|
|
73
51
|
if (!model) return null;
|
|
74
52
|
const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
|
|
75
53
|
return families.find((f) => tokens.has(f)) ?? null;
|
|
76
54
|
}
|
|
77
55
|
|
|
78
|
-
/** Families whose per-model weekly cap gates a switch, given the active model:
|
|
79
|
-
* the model's own family when it is capacity-constrained, none when it is a
|
|
80
|
-
* known-unconstrained model, and EVERY configured family when the model is
|
|
81
|
-
* unknown. The unknown case matters on headless boxes: a swap clears the
|
|
82
|
-
* snapshots and only an actively-rendering statusLine restores the model, so
|
|
83
|
-
* the periodic check ran model-blind for hours while the active account sat at
|
|
84
|
-
* its Fable cap (the 2026-07-12 ARM-box incident). */
|
|
85
56
|
export function gatedFamilies(model: ModelInfo | null, families: string[]): string[] {
|
|
86
57
|
if (!model) return families;
|
|
87
58
|
const family = matchedFamily(model, families);
|
|
@@ -95,13 +66,10 @@ export const FullUsageSchema = z.object({
|
|
|
95
66
|
});
|
|
96
67
|
export type FullUsage = z.infer<typeof FullUsageSchema>;
|
|
97
68
|
|
|
98
|
-
// ---- `/usage` text parsing -----------------------------------------------
|
|
99
|
-
|
|
100
69
|
const MONTHS: Record<string, number> = {
|
|
101
70
|
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
102
71
|
};
|
|
103
72
|
|
|
104
|
-
/** The tz offset (wall-clock minus UTC, in ms) in `tz` at instant `utcMs`. */
|
|
105
73
|
function tzOffsetMs(utcMs: number, tz: string): number {
|
|
106
74
|
const parts = new Intl.DateTimeFormat("en-US", {
|
|
107
75
|
timeZone: tz,
|
|
@@ -115,7 +83,6 @@ function tzOffsetMs(utcMs: number, tz: string): number {
|
|
|
115
83
|
return asUTC - utcMs;
|
|
116
84
|
}
|
|
117
85
|
|
|
118
|
-
/** Interpret a wall-clock time in `tz` as an epoch (DST-correct via one refine). */
|
|
119
86
|
function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min: number, tz: string): number {
|
|
120
87
|
const guess = Date.UTC(y, mon, day, hour, min);
|
|
121
88
|
const off1 = tzOffsetMs(guess, tz);
|
|
@@ -123,16 +90,6 @@ function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min
|
|
|
123
90
|
return guess - off2;
|
|
124
91
|
}
|
|
125
92
|
|
|
126
|
-
/**
|
|
127
|
-
* Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
|
|
128
|
-
* `Jul 10, 3:30pm (Asia/Seoul)` to epoch ms. The day-time glue is not stable
|
|
129
|
-
* across claude installs (observed on 2.1.206: ` at ` on macOS, `, ` on Linux);
|
|
130
|
-
* exactly those two glues are accepted, same-line only, so a third drift shows
|
|
131
|
-
* up as an unparsed clock (and the usage.reset_clock_unparsed log) instead of a
|
|
132
|
-
* guessed instant. The text carries no year, so we pick the year whose resulting
|
|
133
|
-
* instant is nearest `now` (resets are always days away, so the correct year
|
|
134
|
-
* wins by ~360 days). Returns null if unparseable.
|
|
135
|
-
*/
|
|
136
93
|
export function parseResetClock(clock: string, now = Date.now()): number | null {
|
|
137
94
|
const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
|
|
138
95
|
if (!m) return null;
|
|
@@ -151,19 +108,13 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
|
|
|
151
108
|
try {
|
|
152
109
|
epoch = zonedWallToEpoch(y, mon, day, hour, min, tz);
|
|
153
110
|
} catch {
|
|
154
|
-
return null;
|
|
111
|
+
return null;
|
|
155
112
|
}
|
|
156
113
|
if (best === null || Math.abs(epoch - now) < Math.abs(best - now)) best = epoch;
|
|
157
114
|
}
|
|
158
115
|
return best;
|
|
159
116
|
}
|
|
160
117
|
|
|
161
|
-
/** Compact time-until-reset: the largest unit only ("6d", "2h", "45m"),
|
|
162
|
-
* floored to "1m" so a live window never reads as zero, and "" once the reset
|
|
163
|
-
* has passed (the window is simply empty again). Non-empty output always ends
|
|
164
|
-
* in a unit letter, so the statusLine's digit-leading used-percent glued
|
|
165
|
-
* after it stays parseable. Lives here (not cli/render.ts) so headless lib
|
|
166
|
-
* consumers stay off the terminal-rendering module. */
|
|
167
118
|
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
168
119
|
if (epochMs == null) return "";
|
|
169
120
|
const dsec = Math.round((epochMs - now) / 1000);
|
|
@@ -176,99 +127,120 @@ export function fmtResetShort(epochMs: number | null | undefined, now = Date.now
|
|
|
176
127
|
return `${Math.max(m, 1)}m`;
|
|
177
128
|
}
|
|
178
129
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
130
|
+
const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
|
|
131
|
+
export const TranscriptRowSchema = z.looseObject({
|
|
132
|
+
type: z.string().optional(),
|
|
133
|
+
timestamp: z.string().optional(),
|
|
134
|
+
isApiErrorMessage: z.boolean().optional(),
|
|
135
|
+
apiErrorIsTransient: z.boolean().optional(),
|
|
136
|
+
error: z.string().optional(),
|
|
137
|
+
errorDetails: z.string().optional(),
|
|
138
|
+
quotaLimits: z.looseObject({ rateLimitType: z.string().optional(), resetsAt: z.number().optional() }).optional(),
|
|
139
|
+
message: z.looseObject({ content: z.unknown().optional() }).optional(),
|
|
140
|
+
});
|
|
141
|
+
export type TranscriptRow = z.infer<typeof TranscriptRowSchema>;
|
|
142
|
+
|
|
143
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
144
|
+
|
|
145
|
+
export function readTranscriptTail(path: string, maxBytes = TRANSCRIPT_TAIL_BYTES): TranscriptRow[] {
|
|
146
|
+
let text: string;
|
|
147
|
+
try {
|
|
148
|
+
const fd = openSync(path, "r");
|
|
149
|
+
try {
|
|
150
|
+
const size = fstatSync(fd).size;
|
|
151
|
+
const start = Math.max(0, size - maxBytes);
|
|
152
|
+
const buf = Buffer.alloc(size - start);
|
|
153
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
154
|
+
text = buf.toString("utf8");
|
|
155
|
+
if (start > 0) text = text.slice(text.indexOf("\n") + 1);
|
|
156
|
+
} finally {
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
const rows: TranscriptRow[] = [];
|
|
163
|
+
for (const line of text.split("\n")) {
|
|
164
|
+
if (line.trim() === "") continue;
|
|
165
|
+
try {
|
|
166
|
+
const parsed = TranscriptRowSchema.safeParse(JSON.parse(line));
|
|
167
|
+
if (parsed.success) rows.push(parsed.data);
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function transcriptRowText(row: TranscriptRow): string {
|
|
174
|
+
const blocks = z.array(TranscriptBlockSchema).safeParse(row.message?.content);
|
|
175
|
+
if (!blocks.success) return "";
|
|
176
|
+
return blocks.data.filter((b) => b.type === "text").map((b) => b.text ?? "").join("\n").trim();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ROW_RECENCY_MS = 60_000;
|
|
180
|
+
|
|
181
|
+
const EnforcedRowSchema = z.object({
|
|
182
|
+
row: TranscriptRowSchema,
|
|
183
|
+
errorAt: z.number().nullable(),
|
|
184
|
+
});
|
|
185
|
+
export type EnforcedRow = z.infer<typeof EnforcedRowSchema>;
|
|
186
|
+
|
|
187
|
+
export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMessage: string | undefined; now: number }): EnforcedRow | null {
|
|
188
|
+
const { rows, lastAssistantMessage, now } = input;
|
|
189
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
190
|
+
const row = rows[i]!;
|
|
191
|
+
if (row.isApiErrorMessage !== true || row.error !== "rate_limit") continue;
|
|
192
|
+
const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
|
|
193
|
+
const byContent = lastAssistantMessage != null && lastAssistantMessage !== "" && transcriptRowText(row) === lastAssistantMessage;
|
|
194
|
+
const byRecency = Number.isFinite(ts) && Math.abs(now - ts) <= ROW_RECENCY_MS;
|
|
195
|
+
if (!byContent && !byRecency) continue;
|
|
196
|
+
return { row, errorAt: Number.isFinite(ts) ? ts : null };
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const EnforcedClassSchema = z.discriminatedUnion("kind", [
|
|
202
|
+
z.object({ kind: z.literal("session"), resetsAt: z.number().nullable() }),
|
|
203
|
+
z.object({ kind: z.literal("weekly"), resetsAt: z.number().nullable() }),
|
|
204
|
+
z.object({ kind: z.literal("model"), family: z.string(), resetsAt: z.number().nullable() }),
|
|
205
|
+
]);
|
|
206
|
+
export type EnforcedClass = z.infer<typeof EnforcedClassSchema>;
|
|
207
|
+
|
|
208
|
+
const ErrorBodySchema = z.looseObject({
|
|
209
|
+
error: z.looseObject({ type: z.string().optional(), details: z.looseObject({ error_code: z.string().optional() }).optional() }).optional(),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const CREDITS_GATED_FAMILIES = ["fable"];
|
|
213
|
+
|
|
214
|
+
export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof ErrorBodySchema> | null {
|
|
215
|
+
if (!errorDetails) return null;
|
|
216
|
+
const at = errorDetails.indexOf("{");
|
|
186
217
|
if (at < 0) return null;
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
218
|
+
try {
|
|
219
|
+
const body = ErrorBodySchema.safeParse(JSON.parse(errorDetails.slice(at)));
|
|
220
|
+
return body.success ? body.data : null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
191
223
|
}
|
|
192
|
-
// exactly the two real encodings: 10-digit seconds or 13-digit ms. An 11-
|
|
193
|
-
// or 12-digit run is malformed and must stay an unknown reset, not become
|
|
194
|
-
// a far-future one via the seconds branch.
|
|
195
|
-
if (digits.length !== 10 && digits.length !== 13) return null;
|
|
196
|
-
const n = Number(digits);
|
|
197
|
-
return digits.length === 13 ? n : n * 1000;
|
|
198
224
|
}
|
|
199
225
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
* same-org prior snapshot there is nothing safe to write (a synthetic weekly
|
|
215
|
-
* value would flow into `account.lastUsage` and poison the picker's ranking;
|
|
216
|
-
* unmeasured must not look fresh), so the observation is dropped and the
|
|
217
|
-
* retry stays merely bounded.
|
|
218
|
-
*
|
|
219
|
-
* INTENTIONAL TRADEOFF (closing-review critic gap, 2026-07-20): the statusline
|
|
220
|
-
* tee (writeUsage) is deliberately UNLOCKED - the shim stays off flock/oauth -
|
|
221
|
-
* so it can interleave with this locked check-then-write. Last-writer-wins is
|
|
222
|
-
* semantically safe in both orderings: a tee landing after this stamp replaces
|
|
223
|
-
* it with a FRESHER live measurement (truth wins), and this stamp landing
|
|
224
|
-
* after a tee replaces pre-limit figures with the observed limit the server
|
|
225
|
-
* just enforced (also truth). Neither writer can write a stale fabrication
|
|
226
|
-
* over the other; locking the shim to close the interleave would buy nothing.
|
|
227
|
-
*/
|
|
228
|
-
export async function recordObservedLimit(input: { text: string; now: number; org: string | null }): Promise<void> {
|
|
229
|
-
if (!input.org) return;
|
|
230
|
-
// the whole check-then-write runs under the swap flock: a concurrent
|
|
231
|
-
// performSwap clears the snapshots and flips the live org, and a stale
|
|
232
|
-
// depleted write must not land for the wrong org right after that
|
|
233
|
-
// (review catch, PR #18).
|
|
234
|
-
await withLock(paths.lockFile, () => {
|
|
235
|
-
const live = readOAuthAccount()?.organizationUuid ?? null;
|
|
236
|
-
if (live !== input.org) return;
|
|
237
|
-
const prior = loadUsage();
|
|
238
|
-
if (!prior || prior.org !== input.org) return;
|
|
239
|
-
const resetsAt = parseUsageLimitEpoch({ text: input.text });
|
|
240
|
-
// a weekly-phrased limit exhausts the WEEKLY window: stamping only the 5h
|
|
241
|
-
// window would let the picker re-seat this account in 5h while the weekly
|
|
242
|
-
// cap stays dead for days (review catch, PR #18). Unknown-reset blocked
|
|
243
|
-
// windows self-bound at the window's own duration either way.
|
|
244
|
-
const weekly = input.text.toLowerCase().includes("weekly");
|
|
245
|
-
writeUsage({
|
|
246
|
-
fiveHour: weekly ? prior.fiveHour : { usedPercentage: 100, resetsAt },
|
|
247
|
-
sevenDay: weekly ? { usedPercentage: 100, resetsAt } : prior.sevenDay,
|
|
248
|
-
org: input.org,
|
|
249
|
-
ts: input.now,
|
|
250
|
-
model: prior.model,
|
|
251
|
-
});
|
|
252
|
-
log("usage.observed_limit", { resetsAt, weekly });
|
|
253
|
-
});
|
|
226
|
+
export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]): EnforcedClass | null {
|
|
227
|
+
const q = row.quotaLimits;
|
|
228
|
+
if (q) {
|
|
229
|
+
const resetsAt = q.resetsAt != null ? normalizeResetsAt(q.resetsAt) : null;
|
|
230
|
+
const type = q.rateLimitType ?? "";
|
|
231
|
+
if (type === "five_hour") return { kind: "session", resetsAt };
|
|
232
|
+
if (type === "seven_day") return { kind: "weekly", resetsAt };
|
|
233
|
+
const family = switchModels.find((f) => type.includes(f));
|
|
234
|
+
return family ? { kind: "model", family, resetsAt } : null;
|
|
235
|
+
}
|
|
236
|
+
if (row.apiErrorIsTransient === true) return null;
|
|
237
|
+
if (parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
238
|
+
const family = switchModels.find((f) => CREDITS_GATED_FAMILIES.includes(f));
|
|
239
|
+
return family ? { kind: "model", family, resetsAt: null } : null;
|
|
254
240
|
}
|
|
255
241
|
|
|
256
|
-
/**
|
|
257
|
-
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
258
|
-
* Current session: N% used ... resets <clock> → session (5h)
|
|
259
|
-
* Current week (all models): N% used ... resets ... → weekAll (7d aggregate)
|
|
260
|
-
* Current week (<Model>): N% used ... resets ... → perModel[<Model>]
|
|
261
|
-
*/
|
|
262
242
|
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
263
243
|
if (!text) return null;
|
|
264
|
-
// The reset clock is required in full (month day[, | at ]h[:mm]am/pm (tz))
|
|
265
|
-
// inside its optional group, so the lazy bridge is forced to find it when
|
|
266
|
-
// present yet the group cleanly skips a line that has no clock. The bridge
|
|
267
|
-
// refuses to cross another "Current" so a dateless clock ("resets Jul 8.")
|
|
268
|
-
// can never steal the NEXT entry's clock or swallow that entry. The day-time
|
|
269
|
-
// glue is not stable across installs (observed on 2.1.206: ` at ` on macOS,
|
|
270
|
-
// `, ` on Linux); exactly those two are accepted, same-line only, so a third
|
|
271
|
-
// drift surfaces in the usage.reset_clock_unparsed log instead of misparsing.
|
|
272
244
|
const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:(?:(?!current)[^\n])*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
|
|
273
245
|
let session: UsageWindow | null = null;
|
|
274
246
|
let weekAll: UsageWindow | null = null;
|
|
@@ -279,23 +251,16 @@ export function parseUsageTextFull(text: string, now = Date.now()): FullUsage |
|
|
|
279
251
|
else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
|
|
280
252
|
else perModel[m[2]!.trim()] = window;
|
|
281
253
|
}
|
|
282
|
-
// All-or-nothing on the aggregates: every captured /usage output shows both
|
|
283
|
-
// rows, so a missing one is format drift, and fabricating 0% for it would
|
|
284
|
-
// make an unmeasured window read as safe (the one thing it must never do).
|
|
285
254
|
if (!session || !weekAll) return null;
|
|
286
255
|
return { session, weekAll, perModel };
|
|
287
256
|
}
|
|
288
257
|
|
|
289
|
-
/** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
|
|
290
258
|
export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
|
|
291
259
|
const f = parseUsageTextFull(text, now);
|
|
292
260
|
if (!f) return null;
|
|
293
261
|
return { fiveHour: f.session, sevenDay: f.weekAll };
|
|
294
262
|
}
|
|
295
263
|
|
|
296
|
-
/** Env-var identity/credential overrides the claude binary honors BEFORE its
|
|
297
|
-
* keychain lookup (verified 2.1.205). A probe MUST scrub every one of these or
|
|
298
|
-
* an ambient value silently meters the wrong account. */
|
|
299
264
|
export const CRED_ENV_OVERRIDES = [
|
|
300
265
|
"ANTHROPIC_API_KEY",
|
|
301
266
|
"ANTHROPIC_AUTH_TOKEN",
|
|
@@ -307,14 +272,7 @@ export const CRED_ENV_OVERRIDES = [
|
|
|
307
272
|
"CLAUDE_CODE_USE_VERTEX",
|
|
308
273
|
] as const;
|
|
309
274
|
|
|
310
|
-
/** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
|
|
311
|
-
* limit lines (claude prints only a local-stats footer when its own usage fetch
|
|
312
|
-
* errors/throttles) or failed to run. */
|
|
313
|
-
/** A probe child that wedges (auth prompt, dead endpoint) must never block the
|
|
314
|
-
* Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
|
|
315
|
-
* answers in seconds. */
|
|
316
275
|
const PROBE_KILL_MS = 60_000;
|
|
317
|
-
/** How long after the child's death to keep waiting for pipe EOF. */
|
|
318
276
|
const PIPE_GRACE_MS = 2_000;
|
|
319
277
|
|
|
320
278
|
const SpawnResultSchema = z.object({
|
|
@@ -324,14 +282,6 @@ const SpawnResultSchema = z.object({
|
|
|
324
282
|
});
|
|
325
283
|
type SpawnResult = z.infer<typeof SpawnResultSchema>;
|
|
326
284
|
|
|
327
|
-
/** Spawn one bounded claude invocation (shared by the `/usage` probe and the
|
|
328
|
-
* `--force` ping). SIGKILL after PROBE_KILL_MS: claude traps SIGTERM, and a
|
|
329
|
-
* wedged child that survives the kill would keep p.exited pending and re-wedge
|
|
330
|
-
* the read race. Descendants inherit the output pipes, so EOF can lag the
|
|
331
|
-
* child's death or never arrive at all - a leaked grandchild holding the pipe
|
|
332
|
-
* wedged the 2026-07-12 probes forever, defeating the kill guard - so the
|
|
333
|
-
* reads are bounded by child-exit + grace instead of awaiting EOF
|
|
334
|
-
* unconditionally. Returns null when the pipes were withheld past the grace. */
|
|
335
285
|
async function spawnClaudeBounded(
|
|
336
286
|
cmd: string[],
|
|
337
287
|
env: Record<string, string>,
|
|
@@ -380,33 +330,14 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
380
330
|
} else {
|
|
381
331
|
const clockLine = text.split("\n").find((l) => /^current /i.test(l) && /\bresets\b/i.test(l));
|
|
382
332
|
if (clockLine && [full.session, full.weekAll, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
|
|
383
|
-
// Percentages parsed but every reset clock was dropped: the clock format
|
|
384
|
-
// drifted again. Log the line so the next drift is visible in the log.
|
|
385
333
|
log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
|
|
386
334
|
}
|
|
387
335
|
}
|
|
388
336
|
return full;
|
|
389
337
|
}
|
|
390
338
|
|
|
391
|
-
/** Escalating retry delays for the empty-footer case (usage endpoint throttled
|
|
392
|
-
* or the sampled token busy). Capped at ~7s of sleep: probeUsage runs inside
|
|
393
|
-
* Stop/SessionStart hooks and under the status flock, and the periodic `check`
|
|
394
|
-
* timer re-runs every 3 minutes anyway, owning the long-tail retry. */
|
|
395
339
|
const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
396
340
|
|
|
397
|
-
/**
|
|
398
|
-
* Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
|
|
399
|
-
* Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
|
|
400
|
-
* sample the live account. All ambient credential overrides are scrubbed so the
|
|
401
|
-
* probe meters exactly the OAuth credential in the (possibly namespaced)
|
|
402
|
-
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
403
|
-
* transient, so retry with backoff. Returns null if it never yields data.
|
|
404
|
-
*/
|
|
405
|
-
/** The scrubbed env every probe/ping spawn uses. The child spawns the real
|
|
406
|
-
* claude DIRECTLY - it never legitimately passes through the wrapper again -
|
|
407
|
-
* so the depth is preset to the cap and a poisoned pin that leads back to the
|
|
408
|
-
* wrapper aborts on its first entry (the 2026-07-12 ~1800-process recursion
|
|
409
|
-
* started as exactly this probe). */
|
|
410
341
|
function probeEnv(configDir?: string): Record<string, string> {
|
|
411
342
|
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
412
343
|
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
@@ -428,17 +359,6 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
|
|
|
428
359
|
}
|
|
429
360
|
}
|
|
430
361
|
|
|
431
|
-
// ---- `--force` ping --------------------------------------------------------
|
|
432
|
-
|
|
433
|
-
/** The ping is a REAL (but minimal) inference request: `/usage` is free and
|
|
434
|
-
* starts nothing, while any metered request opens the account's 5h session
|
|
435
|
-
* window at the current instant. haiku: the cheapest model (verified $1/$5 per
|
|
436
|
-
* MTok vs $3+ for every other current tier), and one with no per-model weekly
|
|
437
|
-
* cap (those exist only for Sonnet and Fable), so a ping never adds to a
|
|
438
|
-
* per-model cap the policy gates on; its dent in the aggregate 5h/7d windows
|
|
439
|
-
* is negligible. Hooks are disabled for the nested call
|
|
440
|
-
* (`--settings`, the only supported way; `--bare` would kill keychain reads)
|
|
441
|
-
* even though our own hooks already no-op on the probe env. */
|
|
442
362
|
const PING_ARGS = [
|
|
443
363
|
"-p", "Reply with exactly: ok",
|
|
444
364
|
"--model", "haiku",
|
|
@@ -448,14 +368,6 @@ const PING_ARGS = [
|
|
|
448
368
|
|
|
449
369
|
const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
|
|
450
370
|
|
|
451
|
-
/**
|
|
452
|
-
* Send one minimal metered request so the account's 5h session window starts
|
|
453
|
-
* NOW instead of lying dormant until first real use. Pass `configDir` to ping a
|
|
454
|
-
* parked account through its isolated dir (credential already installed); omit
|
|
455
|
-
* to ping the live login. Runs from an empty scratch cwd so no project context
|
|
456
|
-
* (CLAUDE.md, project settings) inflates the request. Returns null on success,
|
|
457
|
-
* else the failure reason.
|
|
458
|
-
*/
|
|
459
371
|
export async function pingSession(configDir?: string): Promise<string | null> {
|
|
460
372
|
const cwd = join(paths.sampleDir, "ping-cwd");
|
|
461
373
|
const fail = (reason: string): string => {
|
package/src/lib/worktree.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
// Linked-worktree detection for the statusLine. A linked worktree's root holds
|
|
2
|
-
// a .git FILE (a gitdir pointer) where the main checkout has a .git directory.
|
|
3
|
-
// Read the filesystem directly - never a git subprocess.
|
|
4
|
-
|
|
5
1
|
import { statSync } from "node:fs";
|
|
6
2
|
import { basename, dirname, join } from "node:path";
|
|
7
3
|
|
|
8
|
-
/** The worktree root's basename when `dir` is inside a LINKED git worktree;
|
|
9
|
-
* null in a main checkout or outside any repository. Any fs error while
|
|
10
|
-
* walking (EACCES, ENOTDIR, stale mounts) is also null: the status line must
|
|
11
|
-
* never break over an unreadable path. */
|
|
12
4
|
export function worktreeName(dir: string): string | null {
|
|
13
5
|
try {
|
|
14
6
|
for (let cur = dir; ; cur = dirname(cur)) {
|
package/src/main.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Single multi-call binary. Behaves as the `claude` supervisor when invoked as
|
|
3
|
-
// `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
|
|
4
|
-
// dispatches the `tokenmaxxing` CLI.
|
|
5
2
|
|
|
6
3
|
import { basename } from "node:path";
|
|
7
4
|
import { runSupervisor } from "./entries/supervisor.ts";
|
|
8
5
|
import { runStatusline } from "./entries/statusline.ts";
|
|
9
6
|
import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
|
|
10
7
|
import { runStopHook } from "./entries/stophook.ts";
|
|
8
|
+
import { runStopFailureHook } from "./entries/stopfailurehook.ts";
|
|
11
9
|
import { runSessionStart } from "./entries/sessionstart.ts";
|
|
12
10
|
import { cmdInit } from "./cli/init.ts";
|
|
13
11
|
import { cmdAdd } from "./cli/add.ts";
|
|
@@ -27,8 +25,6 @@ import { cmdRename } from "./cli/rename.ts";
|
|
|
27
25
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
28
26
|
import { cmdCheck } from "./cli/check.ts";
|
|
29
27
|
import { cmdConfig } from "./cli/config.ts";
|
|
30
|
-
import { cmdRelay } from "./cli/relay.ts";
|
|
31
|
-
import { runRelayPermissionHook } from "./entries/relaypermission.ts";
|
|
32
28
|
import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
|
|
33
29
|
import { c } from "./cli/render.ts";
|
|
34
30
|
|
|
@@ -49,7 +45,6 @@ function printHelp(): void {
|
|
|
49
45
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
50
46
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
51
47
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
52
|
-
${c.cyan("tokenmaxxing relay")} … durable tmux relay for host agents (turn/decide/status/…); see ${c.cyan("relay --help")}
|
|
53
48
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
54
49
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
55
50
|
${c.cyan("tokenmaxxing rm")} [--codex] <sel>
|
|
@@ -67,7 +62,6 @@ async function main(): Promise<number> {
|
|
|
67
62
|
const argv0 = basename(process.argv0 || process.argv[0] || "");
|
|
68
63
|
const sub = args[0];
|
|
69
64
|
|
|
70
|
-
// supervisor mode: invoked as `claude`, or explicit `__supervise`
|
|
71
65
|
if (argv0 === "claude" || sub === "__supervise") {
|
|
72
66
|
return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
|
|
73
67
|
}
|
|
@@ -75,20 +69,7 @@ async function main(): Promise<number> {
|
|
|
75
69
|
return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
// CLI commands refuse an ambient claude store override (closing-review
|
|
79
|
-
// catch): with CLAUDE_CONFIG_DIR set, claude reads a hash-namespaced
|
|
80
|
-
// keychain item and a relocated .claude.json while this tool's identity,
|
|
81
|
-
// swap, and sampling machinery target the default store - `xx init` would
|
|
82
|
-
// silently import whatever stale login lives in the default location. The
|
|
83
|
-
// SDK's pooledSpawnEnv fails fast on exactly this; the CLI now matches.
|
|
84
|
-
// Scoped to COMMANDS only: the __-entries (hooks/statusline) and the
|
|
85
|
-
// supervisor arms above must never break a session claude itself launched
|
|
86
|
-
// with that env - sessions run under an ambient override are outside the
|
|
87
|
-
// managed envelope, like claude's bg-daemon bypass.
|
|
88
72
|
if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
|
|
89
|
-
// first NONEMPTY value, secure-storage first (claude's own precedence):
|
|
90
|
-
// `??` alone let an empty CLAUDE_CONFIG_DIR mask a set SECURESTORAGE
|
|
91
|
-
// override (cubic review catch, PR #35).
|
|
92
73
|
const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
|
|
93
74
|
const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
|
|
94
75
|
if (ambient != null) {
|
|
@@ -101,20 +82,16 @@ async function main(): Promise<number> {
|
|
|
101
82
|
case "__statusline": return runStatusline();
|
|
102
83
|
case "__subagent-statusline": return runSubagentStatusline();
|
|
103
84
|
case "__stop-hook": return runStopHook();
|
|
85
|
+
case "__stop-failure-hook": return runStopFailureHook();
|
|
104
86
|
case "__session-start": return runSessionStart();
|
|
105
87
|
case "__codex-stop-hook": return runCodexStopHook();
|
|
106
|
-
case
|
|
107
|
-
case
|
|
108
|
-
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
109
|
-
// --codex accepted anywhere, like init/add/status: the old args[1]-only
|
|
110
|
-
// check made `xx switch <sel> --codex` silently run a real CLAUDE swap
|
|
111
|
-
// (one email can hold both pools' accounts - closing-review catch).
|
|
112
|
-
case "relay": return cmdRelay(args.slice(1));
|
|
88
|
+
case undefined: return cmdStatus();
|
|
89
|
+
case "--force": return cmdStatus(true);
|
|
113
90
|
case "switch": {
|
|
114
91
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
115
92
|
return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
|
|
116
93
|
}
|
|
117
|
-
case "check": return cmdCheck();
|
|
94
|
+
case "check": return cmdCheck(args.slice(1));
|
|
118
95
|
case "config": return cmdConfig(args.slice(1));
|
|
119
96
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
120
97
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
@@ -123,9 +100,6 @@ async function main(): Promise<number> {
|
|
|
123
100
|
case "status": return cmdStatus(args.includes("--force"));
|
|
124
101
|
case "watch": return cmdWatch(args[1]);
|
|
125
102
|
case "doctor": return cmdDoctor();
|
|
126
|
-
// --codex accepted anywhere, like switch/rename: the two pools are
|
|
127
|
-
// separate namespaces and codex accounts were otherwise unremovable
|
|
128
|
-
// (adversarial-review catch).
|
|
129
103
|
case "rm": {
|
|
130
104
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
131
105
|
return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
|
|
@@ -133,9 +107,6 @@ async function main(): Promise<number> {
|
|
|
133
107
|
case "rename": return cmdRename(args.slice(1));
|
|
134
108
|
case "uninstall": {
|
|
135
109
|
const out = uninstallSupervisor();
|
|
136
|
-
// the headline lists only what verifiably happened - claiming the timer
|
|
137
|
-
// or PATH line gone while the outcome flags say otherwise would
|
|
138
|
-
// contradict the warnings below (bugbot review catch, PR #33).
|
|
139
110
|
const removed = [
|
|
140
111
|
"supervisor wrapper",
|
|
141
112
|
"settings entries",
|
|
@@ -160,12 +131,6 @@ async function main(): Promise<number> {
|
|
|
160
131
|
}
|
|
161
132
|
}
|
|
162
133
|
|
|
163
|
-
// The CLI's error boundary: operational failures that deliberately THROW deep
|
|
164
|
-
// in the libs (a locked keychain failing readItem loudly, codexinit's
|
|
165
|
-
// changed-mid-init abort, corrupt state files) must reach the user as one
|
|
166
|
-
// clean red line with the recovery hint the throw site wrote - not a raw
|
|
167
|
-
// stack trace (bugbot review catch, PR #35). The __-entry subcommands keep
|
|
168
|
-
// their own never-throw contracts and normally never reach this.
|
|
169
134
|
try {
|
|
170
135
|
process.exit(await main());
|
|
171
136
|
} catch (e) {
|