tokenmaxxing 0.19.1 → 1.0.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 +34 -25
- package/README.md +6 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/auth.ts +25 -14
- package/src/cli/check.ts +3 -2
- package/src/cli/codexadd.ts +44 -40
- package/src/cli/codexinit.ts +59 -12
- package/src/cli/codexrm.ts +49 -0
- package/src/cli/codexswitch.ts +20 -2
- package/src/cli/config.ts +25 -1
- package/src/cli/doctor.ts +3 -3
- package/src/cli/init.ts +54 -32
- package/src/cli/onboard.ts +62 -45
- package/src/cli/rename.ts +20 -0
- package/src/cli/render.ts +0 -16
- package/src/cli/rm.ts +40 -2
- package/src/cli/serve.ts +638 -115
- package/src/cli/status.ts +69 -23
- package/src/cli/switch.ts +54 -19
- package/src/entries/codexstophook.ts +123 -4
- package/src/entries/codexsupervisor.ts +87 -13
- package/src/entries/sessionstart.ts +1 -1
- package/src/entries/statusline.ts +56 -20
- package/src/entries/stophook.ts +23 -9
- package/src/entries/supervisor.ts +184 -20
- package/src/lib/atomic.ts +28 -6
- package/src/lib/claudebin.ts +2 -2
- package/src/lib/claudejson.ts +5 -5
- package/src/lib/claudelock.ts +112 -37
- package/src/lib/codexauth.ts +18 -3
- package/src/lib/codexbin.ts +1 -1
- package/src/lib/codexdecide.ts +149 -19
- package/src/lib/codexpick.ts +17 -6
- package/src/lib/codexpresence.ts +59 -21
- package/src/lib/codexsample.ts +17 -8
- package/src/lib/codexswap.ts +10 -1
- package/src/lib/credstore.ts +6 -2
- package/src/lib/decide.ts +136 -49
- package/src/lib/install.ts +125 -17
- package/src/lib/keychain.ts +41 -15
- package/src/lib/lock.ts +57 -35
- package/src/lib/log.ts +36 -7
- package/src/lib/oauth.ts +18 -11
- package/src/lib/paths.ts +17 -11
- package/src/lib/picker.ts +11 -3
- package/src/lib/proc.ts +37 -0
- package/src/lib/sample.ts +91 -31
- package/src/lib/sessions.ts +23 -1
- package/src/lib/settings.ts +59 -18
- package/src/lib/slackbridge.ts +581 -81
- package/src/lib/slackstate.ts +159 -12
- package/src/lib/slackstream.ts +123 -20
- package/src/lib/state.ts +131 -35
- package/src/lib/swap.ts +109 -47
- package/src/lib/types.ts +92 -38
- package/src/lib/usage.ts +114 -16
- package/src/main.ts +70 -9
- package/src/serve-plugin/.claude-plugin/plugin.json +4 -0
- package/src/serve-plugin/skills/ask-the-user/SKILL.md +36 -0
- package/src/serve-plugin/skills/serve-session/SKILL.md +50 -0
package/src/lib/types.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { z } from "zod";
|
|
|
7
7
|
|
|
8
8
|
/** OAuth object inside the keychain blob (`claudeAiOauth`). Loose: preserve any
|
|
9
9
|
* extra fields claude may add so a harvest→install round-trip is lossless. */
|
|
10
|
-
|
|
10
|
+
const OAuthCredsSchema = z.looseObject({
|
|
11
11
|
accessToken: z.string(),
|
|
12
12
|
refreshToken: z.string(),
|
|
13
13
|
expiresAt: z.number(),
|
|
@@ -22,7 +22,6 @@ export type OAuthCreds = z.infer<typeof OAuthCredsSchema>;
|
|
|
22
22
|
* sibling state (e.g. per-MCP-server OAuth tokens), which we must preserve when
|
|
23
23
|
* swapping - we only ever replace `claudeAiOauth`. */
|
|
24
24
|
export const CredentialBlobSchema = z.looseObject({ claudeAiOauth: OAuthCredsSchema });
|
|
25
|
-
export type CredentialBlob = z.infer<typeof CredentialBlobSchema>;
|
|
26
25
|
|
|
27
26
|
/** The `oauthAccount` identity object in ~/.claude.json. Loose: preserve every
|
|
28
27
|
* key so we can reinstall it verbatim on activation. Only the three ids are
|
|
@@ -48,13 +47,13 @@ export const UsageWindowSchema = z.object({
|
|
|
48
47
|
});
|
|
49
48
|
export type UsageWindow = z.infer<typeof UsageWindowSchema>;
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
const UsageWindowsSchema = z.object({
|
|
52
51
|
fiveHour: UsageWindowSchema,
|
|
53
52
|
sevenDay: UsageWindowSchema,
|
|
54
53
|
});
|
|
55
54
|
export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
56
55
|
|
|
57
|
-
|
|
56
|
+
const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
58
57
|
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
59
58
|
|
|
60
59
|
/** usage.json - written by the statusLine shim, read by the Stop hook. Carries
|
|
@@ -73,6 +72,12 @@ export const ModelUsageStateSchema = z.object({
|
|
|
73
72
|
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
74
73
|
org: z.string().nullable(),
|
|
75
74
|
ts: z.number(),
|
|
75
|
+
/** when the rows were actually MEASURED. `ts` is the write time and drives
|
|
76
|
+
* the probe-TTL anti-storm, so a failed probe re-stamps it while carrying
|
|
77
|
+
* the OLD rows forward - dating those rows by ts rolled the null-reset
|
|
78
|
+
* self-bound forward on every failed probe (closing-review catch). Absent
|
|
79
|
+
* on records predating this field: readers fall back to ts. */
|
|
80
|
+
sampledAt: z.number().optional(),
|
|
76
81
|
});
|
|
77
82
|
export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
|
|
78
83
|
|
|
@@ -87,9 +92,17 @@ export const AccountSchema = z.object({
|
|
|
87
92
|
addedAt: z.string(),
|
|
88
93
|
lastUsage: UsageWindowsSchema.optional(),
|
|
89
94
|
lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
|
|
90
|
-
/** epoch ms of the sample behind
|
|
91
|
-
*
|
|
92
|
-
*
|
|
95
|
+
/** epoch ms of the sample behind lastPerModel SPECIFICALLY: the aggregate
|
|
96
|
+
* stamp (lastUsageAt) advances on every engaged evaluation while the
|
|
97
|
+
* per-model rows refresh only when a gated family is measured, so dating
|
|
98
|
+
* the rows by lastUsageAt inflated the null-reset self-bound by days
|
|
99
|
+
* (closing-review catch). Absent on records predating this field: readers
|
|
100
|
+
* fall back to lastUsageAt, the previous (over-)approximation. */
|
|
101
|
+
lastPerModelAt: z.number().optional(),
|
|
102
|
+
/** epoch ms of the sample behind the AGGREGATE lastUsage windows (per-model
|
|
103
|
+
* rows date by lastPerModelAt above). resetsAt values are absolute epochs
|
|
104
|
+
* (UTC-anchored), so even an old snapshot still resolves to correct
|
|
105
|
+
* resets - display it as a dated cache, never discard. */
|
|
93
106
|
lastUsageAt: z.number().optional(),
|
|
94
107
|
needsReauth: z.boolean().optional(),
|
|
95
108
|
subscriptionType: z.string().optional(),
|
|
@@ -120,43 +133,61 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
|
120
133
|
* trigger is the greedy pace-pressure convergence (policy.greedySessionFloor). */
|
|
121
134
|
export const ThresholdsSchema = z.object({
|
|
122
135
|
/** 5h session window. */
|
|
123
|
-
session: z.number(),
|
|
136
|
+
session: z.number().min(0).max(100),
|
|
124
137
|
/** 7-day aggregate AND per-model weekly caps. */
|
|
125
|
-
weekly: z.number(),
|
|
138
|
+
weekly: z.number().min(0).max(100),
|
|
126
139
|
});
|
|
127
140
|
export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
128
141
|
|
|
129
|
-
export const ConfigSchema = z
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
142
|
+
export const ConfigSchema = z
|
|
143
|
+
.object({
|
|
144
|
+
thresholds: ThresholdsSchema,
|
|
145
|
+
claudeBin: z.string(),
|
|
146
|
+
/** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
|
|
147
|
+
codexBin: z.string(),
|
|
148
|
+
policy: z.object({
|
|
149
|
+
/** percent margin subtracted from every bar (effectiveBars); above 100 the
|
|
150
|
+
* effective bars go negative and everything reads exhausted, so bounded. */
|
|
151
|
+
projectionMargin: z.number().min(0).max(100),
|
|
152
|
+
/** session-used % at which the greedy convergence engages: from here on,
|
|
153
|
+
* every evaluation swaps to the usable account furthest behind its weekly
|
|
154
|
+
* pace whenever that beats the current one (idempotent; current keeps its
|
|
155
|
+
* seat on ties). Below the floor a fresh session rides its account. */
|
|
156
|
+
greedySessionFloor: z.number().min(0).max(100),
|
|
157
|
+
/** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
|
|
158
|
+
switchModels: z.array(z.string()),
|
|
159
|
+
/** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
|
|
160
|
+
usagePollTtlMs: z.number().int().positive(),
|
|
161
|
+
/** when every account is depleted, auto-wait for a reset only if it is within this window (ms). */
|
|
162
|
+
maxWaitMs: z.number().int().positive(),
|
|
163
|
+
}),
|
|
164
|
+
})
|
|
165
|
+
// Cross-field (review catch, PR #31): effectiveBars subtracts the margin
|
|
166
|
+
// from each threshold, and a bar at or below zero makes EVERY nonnegative
|
|
167
|
+
// usage percentage read as exhausted - the whole pool looks depleted and the
|
|
168
|
+
// switch path churns. Per-field bounds alone cannot see this.
|
|
169
|
+
.refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
170
|
+
message: "policy.projectionMargin must be strictly below both thresholds (effectiveBars would hit zero and every account would read as exhausted)",
|
|
171
|
+
});
|
|
149
172
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
150
173
|
|
|
151
|
-
/** The hook -> supervisor respawn marker
|
|
152
|
-
*
|
|
174
|
+
/** The hook -> supervisor respawn marker. Written only for a depleted-pool
|
|
175
|
+
* wait (plain swaps adopt in place, no respawn). The marker FILE is keyed by
|
|
176
|
+
* the supervisor's PINNED session id (the path it watches - stable for the
|
|
177
|
+
* process's whole life), while `sessionId` carries the CURRENT transcript to
|
|
178
|
+
* resume: after /clear claude mints a new session id, and keying the file by
|
|
179
|
+
* the stdin sid orphaned every marker while the anticipatory pre-park still
|
|
180
|
+
* fired (closing-review HIGH catch; the codex marker was immune by exactly
|
|
181
|
+
* this construction). */
|
|
153
182
|
export const RespawnMarkerSchema = z.object({
|
|
154
183
|
account: z.string(),
|
|
155
184
|
ts: z.number(),
|
|
156
185
|
/** the supervisor waits until this epoch ms before relaunching. */
|
|
157
186
|
waitUntil: z.number(),
|
|
187
|
+
/** the transcript to `--resume`: the hook-stdin session id, which drifts
|
|
188
|
+
* from the pinned id after /clear. */
|
|
189
|
+
sessionId: z.string(),
|
|
158
190
|
});
|
|
159
|
-
export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
|
|
160
191
|
|
|
161
192
|
/** rate_limits + model as they appear in statusLine stdin (epoch-seconds resets). */
|
|
162
193
|
export const RateLimitsStdinSchema = z.looseObject({
|
|
@@ -227,7 +258,6 @@ export const RefreshResponseSchema = z.looseObject({
|
|
|
227
258
|
scope: z.string().optional(),
|
|
228
259
|
token_type: z.string().optional(),
|
|
229
260
|
});
|
|
230
|
-
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
|
231
261
|
|
|
232
262
|
/** Success body of GET /api/oauth/claude_cli/roles - the org a token ACTUALLY
|
|
233
263
|
* belongs to, independent of any stored label. */
|
|
@@ -242,13 +272,12 @@ export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
|
|
242
272
|
/** `tokens` inside $CODEX_HOME/auth.json (verified against a live 0.144.4
|
|
243
273
|
* auth.json). Loose: preserve unknown siblings so a harvest and reinstall
|
|
244
274
|
* round-trip is lossless. */
|
|
245
|
-
|
|
275
|
+
const CodexTokensSchema = z.looseObject({
|
|
246
276
|
id_token: z.string(),
|
|
247
277
|
access_token: z.string(),
|
|
248
278
|
refresh_token: z.string(),
|
|
249
279
|
account_id: z.string().optional(),
|
|
250
280
|
});
|
|
251
|
-
export type CodexTokens = z.infer<typeof CodexTokensSchema>;
|
|
252
281
|
|
|
253
282
|
/** The whole auth.json. Loose: auth_mode, OPENAI_API_KEY (may be null), and
|
|
254
283
|
* future siblings ride along verbatim. */
|
|
@@ -263,7 +292,7 @@ export type CodexAuthJson = z.infer<typeof CodexAuthJsonSchema>;
|
|
|
263
292
|
* duration-driven (the weekly window is PRIMARY on plans whose 5h window was
|
|
264
293
|
* removed in July 2026), so classification must go by windowSeconds, never
|
|
265
294
|
* by primary/secondary position. */
|
|
266
|
-
|
|
295
|
+
const CodexWindowSchema = z.object({
|
|
267
296
|
usedPercentage: z.number(),
|
|
268
297
|
resetsAt: z.number().nullable(),
|
|
269
298
|
windowSeconds: z.number().nullable(),
|
|
@@ -282,13 +311,25 @@ export const CodexUsageSchema = z.object({
|
|
|
282
311
|
});
|
|
283
312
|
export type CodexUsage = z.infer<typeof CodexUsageSchema>;
|
|
284
313
|
|
|
314
|
+
/** A parked-credential file NAME, never a path. These are machine-written
|
|
315
|
+
* (`tokenmaxxing-codex-<id8>`), so a separator here means the index is
|
|
316
|
+
* corrupted - and `join(credsDir, credFile)` would normalize `../` right out
|
|
317
|
+
* of codex-creds, letting `rm --codex` unlink another file (review catch).
|
|
318
|
+
* Refusing at parse time matches the codex loaders' throw-on-unparsable
|
|
319
|
+
* contract and covers the read/write paths with the delete. */
|
|
320
|
+
const BareFileNameSchema = z
|
|
321
|
+
.string()
|
|
322
|
+
.refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
|
|
323
|
+
message: "credFile must be a bare file name, not a path",
|
|
324
|
+
});
|
|
325
|
+
|
|
285
326
|
/** A pooled codex account (codex-accounts.json - NON-secret). */
|
|
286
327
|
export const CodexAccountSchema = z.object({
|
|
287
328
|
accountId: z.string(),
|
|
288
329
|
email: z.string().nullable(),
|
|
289
330
|
label: z.string(),
|
|
290
331
|
planType: z.string().nullable(),
|
|
291
|
-
credFile:
|
|
332
|
+
credFile: BareFileNameSchema,
|
|
292
333
|
addedAt: z.string(),
|
|
293
334
|
needsReauth: z.boolean().optional(),
|
|
294
335
|
lastUsage: z
|
|
@@ -322,4 +363,17 @@ export const CodexRespawnMarkerSchema = z.object({
|
|
|
322
363
|
sessionId: z.string().nullable(),
|
|
323
364
|
ts: z.number(),
|
|
324
365
|
});
|
|
325
|
-
|
|
366
|
+
|
|
367
|
+
/** A cross-session reconcile signal (owner decisions 2026-07-20): the
|
|
368
|
+
* deciding actor saw the addressed supervisor's session running on this
|
|
369
|
+
* pooled NON-LIVE account - healthy or not (a non-live session cannot
|
|
370
|
+
* refresh cross-account and wedges at token expiry) - while the live seat
|
|
371
|
+
* is usable. The session's OWN Stop hook consumes it at its next turn
|
|
372
|
+
* boundary - the only safe respawn point - promoting it into a respawn
|
|
373
|
+
* marker with the session id its stdin alone carries. `accountId` doubles
|
|
374
|
+
* as the staleness guard: a session that already moved accounts drops the
|
|
375
|
+
* signal instead of respawning. */
|
|
376
|
+
export const CodexReconcileMarkerSchema = z.object({
|
|
377
|
+
accountId: z.string(),
|
|
378
|
+
ts: z.number(),
|
|
379
|
+
});
|
package/src/lib/usage.ts
CHANGED
|
@@ -9,8 +9,11 @@ import { join } from "node:path";
|
|
|
9
9
|
import { delay } from "es-toolkit";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
12
|
+
import { readOAuthAccount } from "./claudejson.ts";
|
|
13
|
+
import { withLock } from "./lock.ts";
|
|
12
14
|
import { log } from "./log.ts";
|
|
13
15
|
import { paths } from "./paths.ts";
|
|
16
|
+
import { loadUsage, writeUsage } from "./state.ts";
|
|
14
17
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
15
18
|
|
|
16
19
|
/** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
|
|
@@ -29,17 +32,19 @@ export function normalizeResetsAt(v: unknown): number | null {
|
|
|
29
32
|
return null;
|
|
30
33
|
}
|
|
31
34
|
|
|
32
|
-
const win = (w
|
|
33
|
-
usedPercentage: w
|
|
34
|
-
resetsAt: normalizeResetsAt(w
|
|
35
|
+
const win = (w: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
|
|
36
|
+
usedPercentage: w.used_percentage,
|
|
37
|
+
resetsAt: normalizeResetsAt(w.resets_at),
|
|
35
38
|
});
|
|
36
39
|
|
|
37
|
-
/** Extract the two AGGREGATE windows from statusLine stdin. null if absent.
|
|
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. */
|
|
38
43
|
export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
|
|
39
44
|
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
40
45
|
if (!parsed.success) return null;
|
|
41
46
|
const rl = parsed.data.rate_limits;
|
|
42
|
-
if (!rl ||
|
|
47
|
+
if (!rl?.five_hour || !rl.seven_day) return null;
|
|
43
48
|
return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
|
|
44
49
|
}
|
|
45
50
|
|
|
@@ -153,11 +158,105 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
|
|
|
153
158
|
return best;
|
|
154
159
|
}
|
|
155
160
|
|
|
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
|
+
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
168
|
+
if (epochMs == null) return "";
|
|
169
|
+
const dsec = Math.round((epochMs - now) / 1000);
|
|
170
|
+
if (dsec <= 0) return "";
|
|
171
|
+
const d = Math.floor(dsec / 86400);
|
|
172
|
+
const h = Math.floor((dsec % 86400) / 3600);
|
|
173
|
+
const m = Math.floor((dsec % 3600) / 60);
|
|
174
|
+
if (d > 0) return `${d}d`;
|
|
175
|
+
if (h > 0) return `${h}h`;
|
|
176
|
+
return `${Math.max(m, 1)}m`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The reset epoch a limit result announces ("Claude AI usage limit
|
|
180
|
+
* reached|<epoch>", 10-digit seconds or 13-digit ms). Structural scan, no
|
|
181
|
+
* regex: everything after the pipe up to the first non-digit. Null when the
|
|
182
|
+
* text is a phrase-only limit with no epoch. */
|
|
183
|
+
export function parseUsageLimitEpoch(input: { text: string }): number | null {
|
|
184
|
+
const marker = "usage limit reached|";
|
|
185
|
+
const at = input.text.toLowerCase().indexOf(marker);
|
|
186
|
+
if (at < 0) return null;
|
|
187
|
+
let digits = "";
|
|
188
|
+
for (const ch of input.text.slice(at + marker.length)) {
|
|
189
|
+
if (ch < "0" || ch > "9") break;
|
|
190
|
+
digits += ch;
|
|
191
|
+
}
|
|
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
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Persist a limit observed in a turn RESULT into usage.json so the next
|
|
202
|
+
* decision sees the depleted account immediately. A headless serve/SDK process
|
|
203
|
+
* has no statusLine tee and `loadFreshSnapshots` skips re-probing inside the
|
|
204
|
+
* poll TTL (and `/usage` is fail-silent against the just-limited active token
|
|
205
|
+
* anyway), so without this write a post-limit retry re-decides off the stale
|
|
206
|
+
* pre-limit snapshot and respawns the same depleted account. The session
|
|
207
|
+
* window is stamped 100% with the announced reset: whichever window actually
|
|
208
|
+
* tripped, the account is unusable until then, and the hard path swaps away.
|
|
209
|
+
* `org` is the identity captured AT THE SPAWN BOUNDARY of the turn that
|
|
210
|
+
* failed; the write happens only when that identity is known and still live,
|
|
211
|
+
* so a concurrent thread's mid-turn swap can never get its fresh account
|
|
212
|
+
* stamped depleted by this turn's failure (review catch, PR #18). Without a
|
|
213
|
+
* same-org prior snapshot there is nothing safe to write (a synthetic weekly
|
|
214
|
+
* value would flow into `account.lastUsage` and poison the picker's ranking;
|
|
215
|
+
* unmeasured must not look fresh), so the observation is dropped and the
|
|
216
|
+
* retry stays merely bounded.
|
|
217
|
+
*
|
|
218
|
+
* INTENTIONAL TRADEOFF (closing-review critic gap, 2026-07-20): the statusline
|
|
219
|
+
* tee (writeUsage) is deliberately UNLOCKED - the shim stays off flock/oauth -
|
|
220
|
+
* so it can interleave with this locked check-then-write. Last-writer-wins is
|
|
221
|
+
* semantically safe in both orderings: a tee landing after this stamp replaces
|
|
222
|
+
* it with a FRESHER live measurement (truth wins), and this stamp landing
|
|
223
|
+
* after a tee replaces pre-limit figures with the observed limit the server
|
|
224
|
+
* just enforced (also truth). Neither writer can write a stale fabrication
|
|
225
|
+
* over the other; locking the shim to close the interleave would buy nothing.
|
|
226
|
+
*/
|
|
227
|
+
export async function recordObservedLimit(input: { text: string; now: number; org: string | null }): Promise<void> {
|
|
228
|
+
if (!input.org) return;
|
|
229
|
+
// the whole check-then-write runs under the swap flock: a concurrent
|
|
230
|
+
// performSwap clears the snapshots and flips the live org, and a stale
|
|
231
|
+
// depleted write must not land for the wrong org right after that
|
|
232
|
+
// (review catch, PR #18).
|
|
233
|
+
await withLock(paths.lockFile, () => {
|
|
234
|
+
const live = readOAuthAccount()?.organizationUuid ?? null;
|
|
235
|
+
if (live !== input.org) return;
|
|
236
|
+
const prior = loadUsage();
|
|
237
|
+
if (!prior || prior.org !== input.org) return;
|
|
238
|
+
const resetsAt = parseUsageLimitEpoch({ text: input.text });
|
|
239
|
+
// a weekly-phrased limit exhausts the WEEKLY window: stamping only the 5h
|
|
240
|
+
// window would let the picker re-seat this account in 5h while the weekly
|
|
241
|
+
// cap stays dead for days (review catch, PR #18). Unknown-reset blocked
|
|
242
|
+
// windows self-bound at the window's own duration either way.
|
|
243
|
+
const weekly = input.text.toLowerCase().includes("weekly");
|
|
244
|
+
writeUsage({
|
|
245
|
+
fiveHour: weekly ? prior.fiveHour : { usedPercentage: 100, resetsAt },
|
|
246
|
+
sevenDay: weekly ? { usedPercentage: 100, resetsAt } : prior.sevenDay,
|
|
247
|
+
org: input.org,
|
|
248
|
+
ts: input.now,
|
|
249
|
+
model: prior.model,
|
|
250
|
+
});
|
|
251
|
+
log("usage.observed_limit", { resetsAt, weekly });
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
156
255
|
/**
|
|
157
256
|
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
158
|
-
* Current session: N% used
|
|
159
|
-
* Current week (all models): N% used
|
|
160
|
-
* Current week (<Model>): N% used
|
|
257
|
+
* Current session: N% used ... resets <clock> → session (5h)
|
|
258
|
+
* Current week (all models): N% used ... resets ... → weekAll (7d aggregate)
|
|
259
|
+
* Current week (<Model>): N% used ... resets ... → perModel[<Model>]
|
|
161
260
|
*/
|
|
162
261
|
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
163
262
|
if (!text) return null;
|
|
@@ -179,12 +278,11 @@ export function parseUsageTextFull(text: string, now = Date.now()): FullUsage |
|
|
|
179
278
|
else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
|
|
180
279
|
else perModel[m[2]!.trim()] = window;
|
|
181
280
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
};
|
|
281
|
+
// All-or-nothing on the aggregates: every captured /usage output shows both
|
|
282
|
+
// rows, so a missing one is format drift, and fabricating 0% for it would
|
|
283
|
+
// make an unmeasured window read as safe (the one thing it must never do).
|
|
284
|
+
if (!session || !weekAll) return null;
|
|
285
|
+
return { session, weekAll, perModel };
|
|
188
286
|
}
|
|
189
287
|
|
|
190
288
|
/** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
|
|
@@ -269,7 +367,7 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
269
367
|
}
|
|
270
368
|
out = r.stdout;
|
|
271
369
|
} catch (e) {
|
|
272
|
-
log("usage.probe_failed", { err:
|
|
370
|
+
log("usage.probe_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
273
371
|
return null;
|
|
274
372
|
}
|
|
275
373
|
|
|
@@ -368,7 +466,7 @@ export async function pingSession(configDir?: string): Promise<string | null> {
|
|
|
368
466
|
mkdirSync(cwd, { recursive: true });
|
|
369
467
|
r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
|
|
370
468
|
} catch (e) {
|
|
371
|
-
return fail(
|
|
469
|
+
return fail(e instanceof Error ? e.message : String(e));
|
|
372
470
|
}
|
|
373
471
|
if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
|
|
374
472
|
if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
|
package/src/main.ts
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// `claude` (or `__supervise`), routes hook/statusLine subcommands, and otherwise
|
|
4
4
|
// dispatches the `tokenmaxxing` CLI.
|
|
5
5
|
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import { basename } from "node:path";
|
|
8
|
+
import { paths } from "./lib/paths.ts";
|
|
7
9
|
import { runSupervisor } from "./entries/supervisor.ts";
|
|
8
10
|
import { runStatusline } from "./entries/statusline.ts";
|
|
9
11
|
import { runSubagentStatusline } from "./entries/subagentstatusline.ts";
|
|
@@ -22,12 +24,13 @@ import { cmdStatus } from "./cli/status.ts";
|
|
|
22
24
|
import { cmdWatch } from "./cli/watch.ts";
|
|
23
25
|
import { cmdDoctor } from "./cli/doctor.ts";
|
|
24
26
|
import { cmdRm } from "./cli/rm.ts";
|
|
27
|
+
import { cmdCodexRm } from "./cli/codexrm.ts";
|
|
25
28
|
import { cmdRename } from "./cli/rename.ts";
|
|
26
29
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
27
30
|
import { cmdCheck } from "./cli/check.ts";
|
|
28
31
|
import { cmdConfig } from "./cli/config.ts";
|
|
29
32
|
import { cmdServe } from "./cli/serve.ts";
|
|
30
|
-
import { uninstallSupervisor } from "./lib/install.ts";
|
|
33
|
+
import { timerDeactivationHint, uninstallSupervisor } from "./lib/install.ts";
|
|
31
34
|
import { c } from "./cli/render.ts";
|
|
32
35
|
|
|
33
36
|
function printHelp(): void {
|
|
@@ -47,10 +50,10 @@ function printHelp(): void {
|
|
|
47
50
|
${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
|
|
48
51
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
49
52
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
50
|
-
${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread
|
|
53
|
+
${c.cyan("tokenmaxxing serve")} [setup|link|unlink|links] Slack bridge daemon: mention the bot in a linked channel to open a claude session per thread in the repo checkout
|
|
51
54
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
52
55
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
53
|
-
${c.cyan("tokenmaxxing rm")} <sel>
|
|
56
|
+
${c.cyan("tokenmaxxing rm")} [--codex] <sel>
|
|
54
57
|
${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
|
|
55
58
|
|
|
56
59
|
${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
|
|
@@ -73,6 +76,28 @@ async function main(): Promise<number> {
|
|
|
73
76
|
return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
|
|
74
77
|
}
|
|
75
78
|
|
|
79
|
+
// CLI commands refuse an ambient claude store override (closing-review
|
|
80
|
+
// catch): with CLAUDE_CONFIG_DIR set, claude reads a hash-namespaced
|
|
81
|
+
// keychain item and a relocated .claude.json while this tool's identity,
|
|
82
|
+
// swap, and sampling machinery target the default store - `xx init` would
|
|
83
|
+
// silently import whatever stale login lives in the default location. The
|
|
84
|
+
// SDK's pooledSpawnEnv fails fast on exactly this; the CLI now matches.
|
|
85
|
+
// Scoped to COMMANDS only: the __-entries (hooks/statusline) and the
|
|
86
|
+
// supervisor arms above must never break a session claude itself launched
|
|
87
|
+
// with that env - sessions run under an ambient override are outside the
|
|
88
|
+
// managed envelope, like claude's bg-daemon bypass.
|
|
89
|
+
if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
|
|
90
|
+
// first NONEMPTY value, secure-storage first (claude's own precedence):
|
|
91
|
+
// `??` alone let an empty CLAUDE_CONFIG_DIR mask a set SECURESTORAGE
|
|
92
|
+
// override (cubic review catch, PR #35).
|
|
93
|
+
const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
|
|
94
|
+
const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
|
|
95
|
+
if (ambient != null) {
|
|
96
|
+
console.error(c.red(`CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`));
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
switch (sub) {
|
|
77
102
|
case "__statusline": return runStatusline();
|
|
78
103
|
case "__subagent-statusline": return runSubagentStatusline();
|
|
@@ -81,7 +106,13 @@ async function main(): Promise<number> {
|
|
|
81
106
|
case "__codex-stop-hook": return runCodexStopHook();
|
|
82
107
|
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
83
108
|
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
84
|
-
|
|
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 "switch": {
|
|
113
|
+
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
114
|
+
return args.includes("--codex") ? cmdCodexSwitch(rest[0]) : cmdSwitch(rest[0]);
|
|
115
|
+
}
|
|
85
116
|
case "check": return cmdCheck();
|
|
86
117
|
case "config": return cmdConfig(args.slice(1));
|
|
87
118
|
case "serve": return cmdServe(args.slice(1));
|
|
@@ -92,12 +123,31 @@ async function main(): Promise<number> {
|
|
|
92
123
|
case "status": return cmdStatus(args.includes("--force"));
|
|
93
124
|
case "watch": return cmdWatch(args[1]);
|
|
94
125
|
case "doctor": return cmdDoctor();
|
|
95
|
-
|
|
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
|
+
case "rm": {
|
|
130
|
+
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
131
|
+
return args.includes("--codex") ? cmdCodexRm(rest[0]) : cmdRm(rest[0]);
|
|
132
|
+
}
|
|
96
133
|
case "rename": return cmdRename(args.slice(1));
|
|
97
|
-
case "uninstall":
|
|
98
|
-
uninstallSupervisor();
|
|
99
|
-
|
|
134
|
+
case "uninstall": {
|
|
135
|
+
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
|
+
const removed = [
|
|
140
|
+
"supervisor wrapper",
|
|
141
|
+
"settings entries",
|
|
142
|
+
...(out.timerDeactivated ? ["check timer"] : []),
|
|
143
|
+
...(out.pathLineRemoved ? ["rc PATH line"] : []),
|
|
144
|
+
];
|
|
145
|
+
console.log(`removed ${removed.join(", ")}`);
|
|
146
|
+
if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
|
|
147
|
+
if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
|
|
148
|
+
console.log(`kept: accounts.json, config.json${existsSync(paths.slackJson) ? ", slack.json (Slack tokens)" : ""}, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
100
149
|
return 0;
|
|
150
|
+
}
|
|
101
151
|
case "help":
|
|
102
152
|
case "-h":
|
|
103
153
|
case "--help":
|
|
@@ -110,4 +160,15 @@ async function main(): Promise<number> {
|
|
|
110
160
|
}
|
|
111
161
|
}
|
|
112
162
|
|
|
113
|
-
|
|
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
|
+
try {
|
|
170
|
+
process.exit(await main());
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(c.red(e instanceof Error ? e.message : String(e)));
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ask-the-user
|
|
3
|
+
description: Use when you need the requesting user's decision, approval, or missing information to proceed, when you are blocked, or when long-running work they asked about is done, in a Slack-relayed tokenmaxxing serve session. Explains how to ask so the user actually gets notified.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Asking the user from a serve session
|
|
7
|
+
|
|
8
|
+
This session is relayed into a Slack thread. There is no interactive prompt:
|
|
9
|
+
the AskUserQuestion tool is unavailable, and only what you write in your reply
|
|
10
|
+
reaches anyone. A plain reply does not notify the user; a mention does.
|
|
11
|
+
|
|
12
|
+
## How to ask
|
|
13
|
+
|
|
14
|
+
1. Write the question into your reply and tag the requester by including their
|
|
15
|
+
mention token literally in the text: `<@U...>`, using the requester id from
|
|
16
|
+
the "Slack relay context" note in this turn. Slack renders it as @name and
|
|
17
|
+
notifies them.
|
|
18
|
+
2. State the fork in one short paragraph: what you were doing, the options,
|
|
19
|
+
which one you recommend and why. One question at a time.
|
|
20
|
+
3. End the turn after asking. Do not pick a real fork's option unilaterally,
|
|
21
|
+
do not busy-wait, and do not keep working past the fork: the user's thread
|
|
22
|
+
reply arrives as your next turn and continues this same session.
|
|
23
|
+
|
|
24
|
+
## When to ask (and tag)
|
|
25
|
+
|
|
26
|
+
- A real decision fork: destructive or irreversible actions, spending money or
|
|
27
|
+
metering account quota, a policy or design choice the user would want to
|
|
28
|
+
make, missing credentials or facts only they have.
|
|
29
|
+
- You are blocked and cannot proceed.
|
|
30
|
+
- You finished long-running work they asked to be told about.
|
|
31
|
+
|
|
32
|
+
## When not to tag
|
|
33
|
+
|
|
34
|
+
- Routine replies and progress: the user reads the thread, and a mention that
|
|
35
|
+
is not actionable trains them to ignore the real ones.
|
|
36
|
+
- Never mention @here, @channel, or @everyone.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: serve-session
|
|
3
|
+
description: How a tokenmaxxing serve session runs - the shared repo checkout, session resume across turns, when to cut your own git worktree, and how to hand finished work back. Use when deciding where to work, commit, push, or open a PR, or before any operation that moves, deletes, or switches the working directory.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# How a serve session runs
|
|
7
|
+
|
|
8
|
+
tokenmaxxing serve bridges a Slack thread to this Claude Code session. Every
|
|
9
|
+
thread message becomes one turn; your streamed output posts back into the
|
|
10
|
+
thread as Slack messages.
|
|
11
|
+
|
|
12
|
+
## Working directory
|
|
13
|
+
|
|
14
|
+
- This session runs directly IN the linked repo checkout, and it is SHARED:
|
|
15
|
+
the repo's owner and other Slack threads work in the same checkout at the
|
|
16
|
+
same time. Read-only work (answering questions, reviewing code, reading
|
|
17
|
+
logs or history) happens here freely and in parallel. Uncommitted changes
|
|
18
|
+
you do not recognize belong to someone else - never reset, restore, or
|
|
19
|
+
stash over them; stage only your own hunks; on a collision, stop and ask
|
|
20
|
+
(see the ask-the-user skill).
|
|
21
|
+
- MUTATING work cuts its own worktree FIRST (`git worktree add`). If the
|
|
22
|
+
task will edit, create, move, or delete files - or run anything that
|
|
23
|
+
writes into the tree - do it from a fresh worktree by default: other
|
|
24
|
+
threads and the owner share this checkout, and two agents editing one tree
|
|
25
|
+
corrupt each other silently. Say in the thread that you cut one, do the
|
|
26
|
+
task's whole life there (follow-ups included), and remove it once the work
|
|
27
|
+
is merged. Skip the worktree only when the user explicitly asked for a
|
|
28
|
+
change in this checkout itself.
|
|
29
|
+
- Your session's cwd stays the SHARED CHECKOUT on every turn regardless (each
|
|
30
|
+
turn is a fresh process at the recorded cwd, and resume is keyed to it, so
|
|
31
|
+
the daemon can never move it). Reach your worktree by absolute path - or
|
|
32
|
+
`cd` inside each command - every turn; never rely on a previous turn's
|
|
33
|
+
`cd`, and re-read the thread for the worktree path when resuming a task.
|
|
34
|
+
- Session resume is keyed to the session's cwd. Never delete or move it, and
|
|
35
|
+
never switch the checkout's branch without asking: the thread (and other
|
|
36
|
+
people's work) would break.
|
|
37
|
+
|
|
38
|
+
## Turns
|
|
39
|
+
|
|
40
|
+
- Each turn is a fresh claude process resumed by session id: the conversation
|
|
41
|
+
transcript carries over, but background processes and unsaved in-memory
|
|
42
|
+
state do not. Persist anything a later turn needs to files.
|
|
43
|
+
- Nothing can answer an interactive dialog mid-turn. To get the user's input,
|
|
44
|
+
follow the ask-the-user skill.
|
|
45
|
+
|
|
46
|
+
## Handing work back
|
|
47
|
+
|
|
48
|
+
- Follow the repo's own shipping conventions (its AGENTS.md or CLAUDE.md);
|
|
49
|
+
when in doubt, branch and open a PR rather than committing to the default
|
|
50
|
+
branch. Push or merge only when the user asks.
|