tokenmaxxing 0.4.0 → 0.5.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 +3 -3
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/cli/add.ts +1 -1
- package/src/cli/init.ts +0 -1
- package/src/cli/render.ts +31 -9
- package/src/cli/status.ts +1 -1
- package/src/entries/statusline.ts +160 -32
- package/src/entries/supervisor.ts +2 -2
- package/src/lib/decide.ts +2 -20
- package/src/lib/install.ts +2 -4
- package/src/lib/keychain.ts +1 -1
- package/src/lib/log.ts +1 -1
- package/src/lib/picker.ts +10 -7
- package/src/lib/settings.ts +7 -38
- package/src/lib/types.ts +26 -0
- package/src/lib/usage.ts +20 -2
- package/src/lib/worktree.ts +22 -0
package/DESIGN.md
CHANGED
|
@@ -4,7 +4,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
|
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
|
|
6
6
|
>
|
|
7
|
-
> Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun → single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a
|
|
7
|
+
> Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun → single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-...`, JSON body). What the acceptance gate actually shows is in §9.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -57,7 +57,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
|
|
|
57
57
|
### 3.4 Swap sequence (under the lock)
|
|
58
58
|
1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
|
|
59
59
|
2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
|
|
60
|
-
3. **Install B** - `security add-generic-password -U
|
|
60
|
+
3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
|
|
61
61
|
4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
|
|
62
62
|
5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
|
|
63
63
|
|
|
@@ -114,7 +114,7 @@ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/u
|
|
|
114
114
|
|
|
115
115
|
Unit suite: **32 pass**. Hermetic swap+concurrency+model-aware E2E: **all pass**. CLI init/doctor/uninstall: **pass** (re-verified init/doctor 2026-07-09 through the npm-installed bun shim on linux-arm64 after the switch to source packaging; full suite 47 pass / 0 fail there).
|
|
116
116
|
|
|
117
|
-
1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p
|
|
117
|
+
1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p ...` committed a clean 12-line transcript; `claude --resume X -p ...` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
|
|
118
118
|
2. **Terminal restoration - ✅ mechanism proven.** The supervisor saves `stty -g` and restores it between kill and respawn; the path executes in the mock-supervisor run. Visual raw-mode/resize confirmation wants a live interactive terminal.
|
|
119
119
|
3. **SessionStart swap - ✅ swap logic proven; ⚠️ "adopted by first turn" needs a 2nd real account.** The hook's decision+swap path is covered by the E2E; the claude-internal timing (SessionStart before first `getToken`) needs a second subscription to observe end-to-end.
|
|
120
120
|
4. **Keychain writes from a headless hook - ✅ proven.** The swap E2E's 4 concurrent subprocesses each wrote the live item via `security -i` (secret on stdin, never argv) with no prompt/hang; `init` parked backups headlessly too. (Residual: the live `Claude Code-credentials` ACL for the *fresh resumed claude* is mitigated by onboarding touching `security` interactively.)
|
package/README.md
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ claude
|
|
9
|
-
|
|
10
|
-
↻ tokenmaxxing: switched to work@acme.com - resuming
|
|
11
|
-
|
|
9
|
+
...you work normally...
|
|
10
|
+
↻ tokenmaxxing: switched to work@acme.com - resuming...
|
|
11
|
+
...same conversation, fresh quota...
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
## Why
|
|
@@ -24,7 +24,7 @@ bun add -g tokenmaxxing
|
|
|
24
24
|
tokenmaxxing init
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
`init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (
|
|
27
|
+
`init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (the tokenmaxxing statusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
|
|
28
28
|
|
|
29
29
|
```sh
|
|
30
30
|
tokenmaxxing add # logs one in, in isolation, and pools it
|
|
@@ -47,7 +47,7 @@ claude # use claude as always
|
|
|
47
47
|
|
|
48
48
|
Switching triggers at **95%** (configurable) on any of:
|
|
49
49
|
|
|
50
|
-
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by
|
|
50
|
+
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
51
51
|
- **Per-model weekly cap** - the capable models (Fable, Opus) have their own tighter weekly limit that binds *before* the aggregate. tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
|
|
52
52
|
|
|
53
53
|
The 5% headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/add.ts
CHANGED
|
@@ -94,7 +94,7 @@ export async function cmdAdd(): Promise<number> {
|
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
97
|
-
console.log(c.dim("sampling usage
|
|
97
|
+
console.log(c.dim("sampling usage..."));
|
|
98
98
|
const sampled = await probeUsage(onboardDir);
|
|
99
99
|
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
100
100
|
|
package/src/cli/init.ts
CHANGED
|
@@ -120,7 +120,6 @@ export async function cmdInit(): Promise<number> {
|
|
|
120
120
|
console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
|
|
121
121
|
console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
|
|
122
122
|
reportTimer(out);
|
|
123
|
-
if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
|
|
124
123
|
if (!out.pathAhead) {
|
|
125
124
|
console.log();
|
|
126
125
|
ensurePathAhead();
|
package/src/cli/render.ts
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
import { clamp } from "es-toolkit";
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/** ANSI painters, no-ops when disabled. The statusLine renders through a pipe
|
|
6
|
+
* (never a TTY), so it needs its own enable gate; the CLI gates on stdout. */
|
|
7
|
+
export function makeColors(enabled: boolean) {
|
|
8
|
+
const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
9
|
+
return {
|
|
10
|
+
dim: paint("2"),
|
|
11
|
+
bold: paint("1"),
|
|
12
|
+
green: paint("32"),
|
|
13
|
+
yellow: paint("33"),
|
|
14
|
+
red: paint("31"),
|
|
15
|
+
cyan: paint("36"),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
6
18
|
|
|
7
|
-
export const c =
|
|
8
|
-
dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
9
|
-
bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
10
|
-
green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
|
11
|
-
yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
|
12
|
-
red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
13
|
-
cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
|
|
14
|
-
};
|
|
19
|
+
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
15
20
|
|
|
16
21
|
/** A fixed-width usage bar, colored by fill. */
|
|
17
22
|
export function bar(pct: number, width = 16): string {
|
|
@@ -34,3 +39,20 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
|
|
|
34
39
|
if (h > 0) return `resets in ${h}h${m}m`;
|
|
35
40
|
return `resets in ${m}m`;
|
|
36
41
|
}
|
|
42
|
+
|
|
43
|
+
/** Compact time-until-reset for the statusLine: the largest unit only ("6d",
|
|
44
|
+
* "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
|
|
45
|
+
* once the reset has passed (the window is simply empty again). Non-empty
|
|
46
|
+
* output always ends in a unit letter, so the digit-leading used-percent glued
|
|
47
|
+
* after it stays parseable. */
|
|
48
|
+
export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
|
|
49
|
+
if (epochMs == null) return "";
|
|
50
|
+
const dsec = Math.round((epochMs - now) / 1000);
|
|
51
|
+
if (dsec <= 0) return "";
|
|
52
|
+
const d = Math.floor(dsec / 86400);
|
|
53
|
+
const h = Math.floor((dsec % 86400) / 3600);
|
|
54
|
+
const m = Math.floor((dsec % 3600) / 60);
|
|
55
|
+
if (d > 0) return `${d}d`;
|
|
56
|
+
if (h > 0) return `${h}h`;
|
|
57
|
+
return `${Math.max(m, 1)}m`;
|
|
58
|
+
}
|
package/src/cli/status.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function cmdStatus(): Promise<number> {
|
|
|
28
28
|
// Load, sample, and save entirely under the flock: parked refreshes must not
|
|
29
29
|
// collide with an in-flight swap, and a save of an index loaded before a
|
|
30
30
|
// concurrent swap would clobber the swap's activeAccountUuid.
|
|
31
|
-
console.error(c.dim("sampling live usage
|
|
31
|
+
console.error(c.dim("sampling live usage..."));
|
|
32
32
|
const outcomes = new Map<string, SampleOutcome>();
|
|
33
33
|
await withLock(paths.lockFile, async () => {
|
|
34
34
|
idx = loadAccounts();
|
|
@@ -1,58 +1,186 @@
|
|
|
1
|
-
// statusLine
|
|
2
|
-
// usage.json (write-on-change, O(ms)), then
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 (effort), ctx used,
|
|
4
|
+
// +added/-removed, then quota as USED percent (bold, severity-colored)
|
|
5
|
+
// glued after its time-to-reset: "◆ F26 2h5 1d38 ◇ F67 2d40 ◇ 2 full"
|
|
6
|
+
// ("2h5" = resets in 2h, 5 used) - the active account's windows after a
|
|
7
|
+
// green ◆ (per-model by initial first, "F?" when the cap applies but is
|
|
8
|
+
// unmeasured, then session/5h, then week), then each parked account's week
|
|
9
|
+
// after a cyan ◇, in swap-preference order so the first usable ◇ is where
|
|
10
|
+
// the next swap lands. Adjacent untouched (or unsampled) parked accounts
|
|
11
|
+
// collapse into one counted token. Blocks are joined by TWO spaces, tokens
|
|
12
|
+
// within a block by one. Per-model resets are omitted (they match the
|
|
13
|
+
// weekly reset).
|
|
14
|
+
// Must NEVER break the status line: render what parses, skip what doesn't.
|
|
5
15
|
|
|
16
|
+
import { sortBy } from "es-toolkit";
|
|
17
|
+
import { z } from "zod";
|
|
6
18
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import
|
|
19
|
+
import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
|
|
20
|
+
import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
21
|
+
import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
|
|
22
|
+
import { worktreeName } from "../lib/worktree.ts";
|
|
23
|
+
import { fmtResetShort, makeColors } from "../cli/render.ts";
|
|
24
|
+
import {
|
|
25
|
+
AccountsIndexSchema,
|
|
26
|
+
StatusLineStdinSchema,
|
|
27
|
+
UsageWindowSchema,
|
|
28
|
+
type Account,
|
|
29
|
+
type UsageState,
|
|
30
|
+
type UsageWindow,
|
|
31
|
+
} from "../lib/types.ts";
|
|
11
32
|
|
|
12
33
|
/** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
|
|
13
34
|
* the OLD account's windows while ~/.claude.json already names the NEW org.
|
|
14
35
|
* Suppress the tee for this long after a swap so that mislabel never lands. */
|
|
15
36
|
const ADOPTION_GRACE_MS = 45_000;
|
|
16
37
|
|
|
38
|
+
const RenderCtxSchema = z.object({
|
|
39
|
+
accounts: AccountsIndexSchema,
|
|
40
|
+
/** active account's per-model weekly windows ({} unless fresh for the live org). */
|
|
41
|
+
perModel: z.record(z.string(), UsageWindowSchema),
|
|
42
|
+
/** families (lowercased) whose per-model weekly cap gates a switch. */
|
|
43
|
+
switchModels: z.array(z.string()),
|
|
44
|
+
threshold: z.number(),
|
|
45
|
+
/** linked-worktree basename, null in a main checkout. */
|
|
46
|
+
worktree: z.string().nullable(),
|
|
47
|
+
now: z.number(),
|
|
48
|
+
color: z.boolean(),
|
|
49
|
+
});
|
|
50
|
+
export type RenderCtx = z.infer<typeof RenderCtxSchema>;
|
|
51
|
+
|
|
17
52
|
async function readStdin(): Promise<string> {
|
|
18
53
|
const chunks: Uint8Array[] = [];
|
|
19
54
|
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
20
55
|
return Buffer.concat(chunks).toString("utf8");
|
|
21
56
|
}
|
|
22
57
|
|
|
58
|
+
/** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
|
|
59
|
+
export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
60
|
+
const col = makeColors(ctx.color);
|
|
61
|
+
const severity = (u: number) => (u >= 95 ? col.red : u >= 75 ? col.yellow : col.green);
|
|
62
|
+
const gauge = (u: number) => col.bold(severity(u)(`${Math.round(u)}`));
|
|
63
|
+
// Used quota in a window; one whose reset has passed is empty again.
|
|
64
|
+
const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
|
|
65
|
+
const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
|
|
66
|
+
|
|
67
|
+
const parsed = StatusLineStdinSchema.safeParse(stdinObj);
|
|
68
|
+
const d = parsed.success ? parsed.data : null;
|
|
69
|
+
|
|
70
|
+
// ---- info block: worktree, model, ctx, diff
|
|
71
|
+
const info: string[] = [];
|
|
72
|
+
if (ctx.worktree != null) info.push(ctx.worktree);
|
|
73
|
+
const modelName = d?.model?.display_name ?? d?.model?.id;
|
|
74
|
+
if (modelName) {
|
|
75
|
+
const effort = d?.effort?.level;
|
|
76
|
+
info.push(col.bold(modelName) + (effort ? ` (${effort})` : ""));
|
|
77
|
+
}
|
|
78
|
+
const ctxUsed = d?.context_window?.used_percentage;
|
|
79
|
+
if (ctxUsed != null) info.push(`ctx ${gauge(ctxUsed)}`);
|
|
80
|
+
const added = d?.cost?.total_lines_added ?? 0;
|
|
81
|
+
const removed = d?.cost?.total_lines_removed ?? 0;
|
|
82
|
+
// -removed stays unpainted: red means quota alarm and nothing else.
|
|
83
|
+
if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
|
|
84
|
+
|
|
85
|
+
// ---- active account block
|
|
86
|
+
const seg = (label: string, w: UsageWindow, resetAt: number | null) =>
|
|
87
|
+
`${label}${reset(resetAt)}${gauge(used(w))}`;
|
|
88
|
+
const wins = parseStatusLineStdin(stdinObj);
|
|
89
|
+
const windows: string[] = [];
|
|
90
|
+
// A capacity-constrained model whose cap is unmeasured must not look safe.
|
|
91
|
+
const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
|
|
92
|
+
if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
|
|
93
|
+
windows.push(`${family[0]!.toUpperCase()}?`);
|
|
94
|
+
}
|
|
95
|
+
for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(name.slice(0, 1), w, null));
|
|
96
|
+
if (wins) {
|
|
97
|
+
windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
|
|
98
|
+
windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
|
|
99
|
+
}
|
|
100
|
+
const active =
|
|
101
|
+
windows.length > 0
|
|
102
|
+
? `${col.green("◆")} ${windows.join(" ")}`
|
|
103
|
+
: ctx.accounts.activeAccountUuid != null
|
|
104
|
+
? `${col.green("◆")} ?`
|
|
105
|
+
: "";
|
|
106
|
+
|
|
107
|
+
// ---- parked accounts, in swap order: the first usable ◇ is the next target
|
|
108
|
+
const pickCtx = { now: ctx.now, threshold: ctx.threshold, currentAccountUuid: ctx.accounts.activeAccountUuid };
|
|
109
|
+
const parked = sortBy(
|
|
110
|
+
ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
|
|
111
|
+
[(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
|
|
112
|
+
);
|
|
113
|
+
const poolSeg = (a: Account): { kind: "full" | "unknown" | "other"; text: string } => {
|
|
114
|
+
const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
|
|
115
|
+
const mergeable = !a.needsReauth;
|
|
116
|
+
|
|
117
|
+
const week = a.lastUsage?.sevenDay;
|
|
118
|
+
if (week == null) return { kind: mergeable ? "unknown" : "other", text: `${marker} ?` };
|
|
119
|
+
const weekUsed = used(week);
|
|
120
|
+
if (Math.round(weekUsed) <= 0) return { kind: mergeable ? "full" : "other", text: `${marker} ${col.green("full")}` };
|
|
121
|
+
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
// A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
|
|
124
|
+
for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
|
|
125
|
+
if (used(w) > weekUsed) parts.push(seg(name.slice(0, 1), w, null));
|
|
126
|
+
}
|
|
127
|
+
const expiry = weeklyExpiry(a, ctx.now);
|
|
128
|
+
parts.push(seg("", week, Number.isFinite(expiry) ? expiry : null));
|
|
129
|
+
return { kind: "other", text: `${marker} ${parts.join(" ")}` };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Adjacent identical bare tokens collapse into one counted token ("◇ 3 full").
|
|
133
|
+
const pool: string[] = [];
|
|
134
|
+
const segs = parked.map(poolSeg);
|
|
135
|
+
for (let i = 0; i < segs.length; ) {
|
|
136
|
+
const s = segs[i]!;
|
|
137
|
+
let j = i + 1;
|
|
138
|
+
while (s.kind !== "other" && j < segs.length && segs[j]!.kind === s.kind) j++;
|
|
139
|
+
if (j - i >= 2) pool.push(`${col.cyan("◇")} ${j - i} ${s.kind === "full" ? col.green("full") : "?"}`);
|
|
140
|
+
else pool.push(s.text);
|
|
141
|
+
i = j;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return [info.join(" "), active, ...pool].filter((l) => l !== "").join(" ");
|
|
145
|
+
}
|
|
146
|
+
|
|
23
147
|
export async function runStatusline(): Promise<number> {
|
|
24
148
|
const raw = await readStdin();
|
|
149
|
+
let obj: unknown = null;
|
|
150
|
+
try {
|
|
151
|
+
obj = JSON.parse(raw);
|
|
152
|
+
} catch {
|
|
153
|
+
// malformed stdin - render from state alone
|
|
154
|
+
}
|
|
155
|
+
const now = Date.now();
|
|
25
156
|
|
|
26
|
-
//
|
|
157
|
+
// tee usage for the Stop hook / status - best effort, never blocks rendering.
|
|
158
|
+
let org: string | null = null;
|
|
27
159
|
try {
|
|
28
|
-
|
|
29
|
-
const windows = parseStatusLineStdin(obj);
|
|
160
|
+
org = readOAuthAccount()?.organizationUuid ?? null;
|
|
161
|
+
const windows = obj == null ? null : parseStatusLineStdin(obj);
|
|
30
162
|
const lastSwapAt = loadLastSwapAt();
|
|
31
|
-
if (windows && (lastSwapAt == null ||
|
|
32
|
-
const
|
|
33
|
-
const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
|
|
163
|
+
if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
|
|
164
|
+
const state: UsageState = { ...windows, org, ts: now, model: parseStatusLineModel(obj) };
|
|
34
165
|
writeUsage(state);
|
|
35
166
|
}
|
|
36
167
|
} catch {
|
|
37
|
-
//
|
|
168
|
+
// skip the tee, still render below
|
|
38
169
|
}
|
|
39
170
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// fall through to no-op
|
|
55
|
-
}
|
|
56
|
-
}
|
|
171
|
+
const cfg = loadConfig();
|
|
172
|
+
const modelUsage = loadModelUsage();
|
|
173
|
+
const stdin = StatusLineStdinSchema.safeParse(obj);
|
|
174
|
+
const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
|
|
175
|
+
const ctx: RenderCtx = {
|
|
176
|
+
accounts: loadAccounts(),
|
|
177
|
+
perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
|
|
178
|
+
switchModels: cfg.policy.switchModels,
|
|
179
|
+
threshold: cfg.threshold,
|
|
180
|
+
worktree: dir == null ? null : worktreeName(dir),
|
|
181
|
+
now,
|
|
182
|
+
color: !process.env.NO_COLOR,
|
|
183
|
+
};
|
|
184
|
+
process.stdout.write(renderStatusline(obj, ctx) + "\n");
|
|
57
185
|
return 0;
|
|
58
186
|
}
|
|
@@ -106,7 +106,7 @@ async function countdownWait(acct: string, until: number): Promise<void> {
|
|
|
106
106
|
await Bun.sleep(1000);
|
|
107
107
|
}
|
|
108
108
|
process.removeListener("SIGINT", onInt);
|
|
109
|
-
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}
|
|
109
|
+
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Entry point: `claude ...args`. */
|
|
@@ -193,7 +193,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
193
193
|
rmSync(marker, { force: true });
|
|
194
194
|
respawns++;
|
|
195
195
|
if (m.waitUntil && m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
|
|
196
|
-
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming
|
|
196
|
+
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
197
197
|
launchArgs = ["--resume", sid, ...base];
|
|
198
198
|
continue;
|
|
199
199
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -20,9 +20,9 @@ import { readOAuthAccount } from "./claudejson.ts";
|
|
|
20
20
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
21
21
|
import { pickEarliestReset, usableAt } from "./picker.ts";
|
|
22
22
|
import { InvalidGrantError } from "./oauth.ts";
|
|
23
|
-
import { probeUsage } from "./usage.ts";
|
|
23
|
+
import { familyTokens, matchedFamily, probeUsage } from "./usage.ts";
|
|
24
24
|
import { log } from "./log.ts";
|
|
25
|
-
import { AccountSchema, type Account, type Config, type
|
|
25
|
+
import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
26
26
|
|
|
27
27
|
const SwapDecisionSchema = z.object({
|
|
28
28
|
swapped: z.boolean(),
|
|
@@ -58,24 +58,6 @@ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsa
|
|
|
58
58
|
return state;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
/** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
|
|
62
|
-
* "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
|
|
63
|
-
* drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
|
|
64
|
-
* historically flipped between family-first and version-first), so gates match
|
|
65
|
-
* a family token anywhere instead of an exact string - an exact-string gate
|
|
66
|
-
* silently disabled the per-model check in the 2026-07-09/10 incidents. */
|
|
67
|
-
export function familyTokens(s: string): string[] {
|
|
68
|
-
return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** The switchModels family the active model belongs to, from its id OR display
|
|
72
|
-
* tokens; null when the model is not capacity-constrained. */
|
|
73
|
-
export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
|
|
74
|
-
if (!model) return null;
|
|
75
|
-
const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
|
|
76
|
-
return families.find((f) => tokens.has(f)) ?? null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
61
|
/** The family's weekly cap among the `/usage` rows; when several rows match the
|
|
80
62
|
* family, the most-used one wins (switching early beats metering a depleted cap). */
|
|
81
63
|
function capForFamily(mu: ModelUsageState, family: string): UsageWindow | undefined {
|
package/src/lib/install.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
|
|
2
|
-
// The wrapper is a 2-line `exec
|
|
2
|
+
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
3
|
// depends on argv0 semantics.
|
|
4
4
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
@@ -14,7 +14,6 @@ import { resolveRealClaude } from "./claudebin.ts";
|
|
|
14
14
|
const InstallOutcomeSchema = z.object({
|
|
15
15
|
claudeWrapper: z.string(),
|
|
16
16
|
installedBin: z.string(),
|
|
17
|
-
priorStatusLine: z.string().nullable(),
|
|
18
17
|
pathAhead: z.boolean(),
|
|
19
18
|
timerLoaded: z.boolean(),
|
|
20
19
|
});
|
|
@@ -48,11 +47,10 @@ export function installSupervisor(): InstallOutcome {
|
|
|
48
47
|
// the `xx` short alias → tokenmaxxing
|
|
49
48
|
writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
|
|
50
49
|
|
|
51
|
-
|
|
50
|
+
installSettings();
|
|
52
51
|
return {
|
|
53
52
|
claudeWrapper: paths.supervisorLink,
|
|
54
53
|
installedBin: target,
|
|
55
|
-
priorStatusLine,
|
|
56
54
|
pathAhead: isBinDirAhead(),
|
|
57
55
|
timerLoaded: installCheckTimer(),
|
|
58
56
|
};
|
package/src/lib/keychain.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// macOS login-keychain generic-password I/O, ps-safe. The darwin backend of
|
|
2
2
|
// credstore.ts - construct targets there, not here.
|
|
3
3
|
// READ : `security find-generic-password -w` → secret only ever in stdout.
|
|
4
|
-
// WRITE : pipe an `add-generic-password -U
|
|
4
|
+
// WRITE : pipe an `add-generic-password -U ... -w <secret>` line into `security -i`
|
|
5
5
|
// over STDIN - the secret never appears in any process's argv (verified).
|
|
6
6
|
// The service/account are non-secret and may sit in argv.
|
|
7
7
|
|
package/src/lib/log.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function redact(s: string): string {
|
|
|
11
11
|
return s
|
|
12
12
|
// JWT-ish / long opaque tokens
|
|
13
13
|
.replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
|
|
14
|
-
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}
|
|
14
|
+
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}...(${m.length})`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function log(event: string, fields: Record<string, unknown> = {}): void {
|
package/src/lib/picker.ts
CHANGED
|
@@ -38,6 +38,15 @@ export function weeklyExpiry(a: Account, now: number): number {
|
|
|
38
38
|
return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The switch preference: soonest weekly expiry first; tiebreak lowest 7-day
|
|
42
|
+
* usage, then soonest 5h reset. Shared with the statusLine pool ordering so
|
|
43
|
+
* the display order IS the swap order. */
|
|
44
|
+
export const swapPreference = (now: number) => [
|
|
45
|
+
(a: Account) => weeklyExpiry(a, now),
|
|
46
|
+
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
47
|
+
(a: Account) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
48
|
+
];
|
|
49
|
+
|
|
41
50
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
42
51
|
const candidates = accounts.filter(
|
|
43
52
|
(a) =>
|
|
@@ -46,13 +55,7 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
46
55
|
!isExhausted(a, ctx),
|
|
47
56
|
);
|
|
48
57
|
if (candidates.length === 0) return null;
|
|
49
|
-
|
|
50
|
-
// soonest weekly expiry first; tiebreak lowest 7-day usage, then soonest 5h reset.
|
|
51
|
-
return sortBy(candidates, [
|
|
52
|
-
(a) => weeklyExpiry(a, ctx.now),
|
|
53
|
-
(a) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
54
|
-
(a) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
55
|
-
])[0]!;
|
|
58
|
+
return sortBy(candidates, swapPreference(ctx.now))[0]!;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** When an account becomes usable again: the latest reset among its over-threshold
|
package/src/lib/settings.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Idempotent merge of tokenmaxxing's three entries into the user-owned
|
|
2
|
-
// ~/.claude/settings.json:
|
|
3
|
-
//
|
|
2
|
+
// ~/.claude/settings.json: our statusLine, a Stop hook, a SessionStart hook.
|
|
3
|
+
// Hooks APPEND to existing arrays; the statusLine slot is ours outright -
|
|
4
|
+
// tokenmaxxing renders it natively, so any other statusLine command is replaced.
|
|
4
5
|
|
|
5
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -23,8 +24,6 @@ const SettingsSchema = z.looseObject({
|
|
|
23
24
|
type Settings = z.infer<typeof SettingsSchema>;
|
|
24
25
|
type HookGroup = z.infer<typeof HookGroupSchema>;
|
|
25
26
|
|
|
26
|
-
const PRIOR_STATUSLINE_FILE = join(paths.home, "prior-statusline.json");
|
|
27
|
-
|
|
28
27
|
const SUBCMD = {
|
|
29
28
|
statusline: "__statusline",
|
|
30
29
|
stop: "__stop-hook",
|
|
@@ -71,57 +70,27 @@ function removeHook(s: Settings, event: string, sub: string): void {
|
|
|
71
70
|
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
export
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Install the three entries. Returns the prior statusLine command that was
|
|
79
|
-
* wrapped (stored to disk so the shim can chain to it and uninstall can restore).
|
|
80
|
-
*/
|
|
81
|
-
export function installSettings(): InstallResult {
|
|
73
|
+
/** Install the three entries: take the statusLine slot, append our hooks. */
|
|
74
|
+
export function installSettings(): void {
|
|
82
75
|
const s = readSettings();
|
|
83
|
-
|
|
84
|
-
// ---- statusLine: capture prior (unless it's already ours), then wrap.
|
|
85
|
-
let prior: string | null;
|
|
86
|
-
if (s.statusLine && !isOurCommand(s.statusLine.command)) {
|
|
87
|
-
prior = s.statusLine.command;
|
|
88
|
-
writeFileAtomic(PRIOR_STATUSLINE_FILE, JSON.stringify({ command: prior }) + "\n", 0o644);
|
|
89
|
-
} else {
|
|
90
|
-
prior = readPriorStatusLine();
|
|
91
|
-
}
|
|
92
76
|
s.statusLine = {
|
|
93
77
|
type: "command",
|
|
94
78
|
command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
|
|
95
79
|
};
|
|
96
|
-
|
|
97
|
-
// ---- hooks: append ours if absent.
|
|
98
80
|
appendHook(s, "Stop", SUBCMD.stop);
|
|
99
81
|
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
100
|
-
|
|
101
82
|
writeSettings(s);
|
|
102
|
-
return { priorStatusLine: prior };
|
|
103
83
|
}
|
|
104
84
|
|
|
105
|
-
/** Remove our three entries
|
|
85
|
+
/** Remove our three entries. The statusLine slot is deleted only if it is ours. */
|
|
106
86
|
export function uninstallSettings(): void {
|
|
107
87
|
const s = readSettings();
|
|
108
88
|
removeHook(s, "Stop", SUBCMD.stop);
|
|
109
89
|
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
110
|
-
if (s.statusLine && isOurCommand(s.statusLine.command))
|
|
111
|
-
const prior = readPriorStatusLine();
|
|
112
|
-
if (prior) s.statusLine = { type: "command", command: prior };
|
|
113
|
-
else delete s.statusLine;
|
|
114
|
-
}
|
|
90
|
+
if (s.statusLine && isOurCommand(s.statusLine.command)) delete s.statusLine;
|
|
115
91
|
writeSettings(s);
|
|
116
92
|
}
|
|
117
93
|
|
|
118
|
-
const PriorStatusLineSchema = z.object({ command: z.string() });
|
|
119
|
-
|
|
120
|
-
export function readPriorStatusLine(): string | null {
|
|
121
|
-
if (!existsSync(PRIOR_STATUSLINE_FILE)) return null;
|
|
122
|
-
return PriorStatusLineSchema.parse(JSON.parse(readFileSync(PRIOR_STATUSLINE_FILE, "utf8"))).command;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
94
|
const SettingsCheckSchema = z.object({
|
|
126
95
|
statusLineOk: z.boolean(),
|
|
127
96
|
stopOk: z.boolean(),
|
package/src/lib/types.ts
CHANGED
|
@@ -137,6 +137,32 @@ export const RateLimitsStdinSchema = z.looseObject({
|
|
|
137
137
|
organizationUuid: z.string().optional(),
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
+
/** The statusLine stdin fields the native renderer consumes, on top of the
|
|
141
|
+
* rate-limit tee's needs. Loose + optional throughout: fields are null before
|
|
142
|
+
* the first API response and claude adds new ones freely. Each sub-object also
|
|
143
|
+
* `.catch(undefined)`es so a field that drifts to a wrong shape degrades to
|
|
144
|
+
* absent instead of failing the whole parse and erasing the info block. */
|
|
145
|
+
export const StatusLineStdinSchema = RateLimitsStdinSchema.extend({
|
|
146
|
+
workspace: z
|
|
147
|
+
.looseObject({
|
|
148
|
+
current_dir: z.string().nullable().optional(),
|
|
149
|
+
project_dir: z.string().nullable().optional(),
|
|
150
|
+
})
|
|
151
|
+
.nullable()
|
|
152
|
+
.optional()
|
|
153
|
+
.catch(undefined),
|
|
154
|
+
context_window: z.looseObject({ used_percentage: z.number().nullable().optional() }).nullable().optional().catch(undefined),
|
|
155
|
+
cost: z
|
|
156
|
+
.looseObject({
|
|
157
|
+
total_lines_added: z.number().nullable().optional(),
|
|
158
|
+
total_lines_removed: z.number().nullable().optional(),
|
|
159
|
+
})
|
|
160
|
+
.nullable()
|
|
161
|
+
.optional()
|
|
162
|
+
.catch(undefined),
|
|
163
|
+
effort: z.looseObject({ level: z.string().optional() }).nullable().optional().catch(undefined),
|
|
164
|
+
});
|
|
165
|
+
|
|
140
166
|
/** Success body of the OAuth refresh grant. */
|
|
141
167
|
export const RefreshResponseSchema = z.looseObject({
|
|
142
168
|
access_token: z.string(),
|
package/src/lib/usage.ts
CHANGED
|
@@ -49,6 +49,24 @@ export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
|
49
49
|
return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
|
|
53
|
+
* "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
|
|
54
|
+
* drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
|
|
55
|
+
* historically flipped between family-first and version-first), so gates match
|
|
56
|
+
* a family token anywhere instead of an exact string - an exact-string gate
|
|
57
|
+
* silently disabled the per-model check in the 2026-07-09/10 incidents. */
|
|
58
|
+
export function familyTokens(s: string): string[] {
|
|
59
|
+
return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The switchModels family the active model belongs to, from its id OR display
|
|
63
|
+
* tokens; null when the model is not capacity-constrained. */
|
|
64
|
+
export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
|
|
65
|
+
if (!model) return null;
|
|
66
|
+
const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
|
|
67
|
+
return families.find((f) => tokens.has(f)) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
export const FullUsageSchema = z.object({
|
|
53
71
|
session: UsageWindowSchema,
|
|
54
72
|
weekAll: UsageWindowSchema,
|
|
@@ -122,8 +140,8 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
|
|
|
122
140
|
/**
|
|
123
141
|
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
124
142
|
* Current session: N% used · resets <clock> → session (5h)
|
|
125
|
-
* Current week (all models): N% used · resets
|
|
126
|
-
* Current week (<Model>): N% used · resets
|
|
143
|
+
* Current week (all models): N% used · resets ... → weekAll (7d aggregate)
|
|
144
|
+
* Current week (<Model>): N% used · resets ... → perModel[<Model>]
|
|
127
145
|
*/
|
|
128
146
|
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
129
147
|
if (!text) return null;
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
import { statSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
|
|
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
|
+
export function worktreeName(dir: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
for (let cur = dir; ; cur = dirname(cur)) {
|
|
15
|
+
const st = statSync(join(cur, ".git"), { throwIfNoEntry: false });
|
|
16
|
+
if (st) return st.isFile() ? basename(cur) : null;
|
|
17
|
+
if (dirname(cur) === cur) return null;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|