tokenmaxxing 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -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 +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +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/cli/onboard.ts
CHANGED
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
// One isolated Claude login, harvested. Shared by `add` (register whatever
|
|
2
|
-
// account the login lands on) and `auth` (require it to match an existing
|
|
3
|
-
// account). Drives the login in a throwaway CLAUDE_CONFIG_DIR - the ONLY use
|
|
4
|
-
// of CLAUDE_CONFIG_DIR in the tool - auto-exits the moment the login lands,
|
|
5
|
-
// samples that account's usage, then returns the credential + identity. The
|
|
6
|
-
// temp dir and isolated credential are always destroyed before returning.
|
|
7
|
-
|
|
8
1
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
9
2
|
import { join } from "node:path";
|
|
10
3
|
import { z } from "zod";
|
|
@@ -17,7 +10,6 @@ import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
|
|
|
17
10
|
import { c } from "./render.ts";
|
|
18
11
|
|
|
19
12
|
const HarvestedLoginSchema = z.object({
|
|
20
|
-
/** the raw isolated credential blob (park it via claudeAiOauthOnly). */
|
|
21
13
|
blobRaw: z.string(),
|
|
22
14
|
blob: CredentialBlobSchema,
|
|
23
15
|
oauthAccount: OAuthAccountSchema,
|
|
@@ -25,7 +17,6 @@ const HarvestedLoginSchema = z.object({
|
|
|
25
17
|
});
|
|
26
18
|
export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
|
|
27
19
|
|
|
28
|
-
/** True once `/login` has written a usable identity into the onboard dir. */
|
|
29
20
|
function identityReady(cjPath: string): boolean {
|
|
30
21
|
if (!existsSync(cjPath)) return false;
|
|
31
22
|
try {
|
|
@@ -36,32 +27,16 @@ function identityReady(cjPath: string): boolean {
|
|
|
36
27
|
}
|
|
37
28
|
}
|
|
38
29
|
|
|
39
|
-
/**
|
|
40
|
-
* Run one isolated login session and harvest it. The caller prints its own
|
|
41
|
-
* instructions (which account to sign in as) BEFORE calling. Prints progress
|
|
42
|
-
* and failure diagnostics; returns null when no usable login landed.
|
|
43
|
-
*/
|
|
44
30
|
export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
45
31
|
const onboardDir = paths.onboardDir;
|
|
46
32
|
rmSync(onboardDir, { recursive: true, force: true });
|
|
47
33
|
mkdirSync(onboardDir, { recursive: true });
|
|
48
34
|
const iso = isolatedTarget(onboardDir);
|
|
49
|
-
// Reap a PREVIOUS run's stranded isolated credential too: on macOS the
|
|
50
|
-
// isolated store is a namespaced KEYCHAIN item, not a file in onboardDir,
|
|
51
|
-
// so the rmSync above never touched it - a signal-killed harvest (Ctrl-C
|
|
52
|
-
// after /login, before the ~400ms poll) left a live credential in the
|
|
53
|
-
// keychain that the next run's spawned claude would silently reuse as an
|
|
54
|
-
// already-authenticated session (closing-review catch). deleteItem on a
|
|
55
|
-
// missing item is a no-op; on Linux iso is a file inside onboardDir and the
|
|
56
|
-
// rmSync already covered it.
|
|
57
35
|
await deleteItem(iso);
|
|
58
36
|
const cjPath = join(onboardDir, ".claude.json");
|
|
59
37
|
const real = resolveRealClaude();
|
|
60
38
|
|
|
61
39
|
const savedTermios = saveTermios();
|
|
62
|
-
// Scrub the ambient credential/identity overrides claude honors BEFORE its
|
|
63
|
-
// keychain lookup (verified 2.1.205) - the onboard session must authenticate
|
|
64
|
-
// only via the /login the user performs inside it.
|
|
65
40
|
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
66
41
|
for (const key of CRED_ENV_OVERRIDES) delete env[key];
|
|
67
42
|
const p = Bun.spawn([real], {
|
|
@@ -71,15 +46,7 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
71
46
|
env,
|
|
72
47
|
});
|
|
73
48
|
|
|
74
|
-
// The finally makes the "always destroyed before returning" header true for
|
|
75
|
-
// every non-signal exit - INCLUDING a failure while the login session is
|
|
76
|
-
// still being polled (review catch, PR #31): an exception must not strand a
|
|
77
|
-
// plaintext credential on disk or leave the spawned claude running. (An
|
|
78
|
-
// interactive Ctrl-C is reaped by the next run's cleanup-first, which
|
|
79
|
-
// deletes the isolated keychain item as well as the dir.)
|
|
80
49
|
try {
|
|
81
|
-
// Auto-exit (#17): watch for a completed login - identity written AND the
|
|
82
|
-
// isolated credential present - then SIGTERM claude. No manual /exit.
|
|
83
50
|
let exited = false;
|
|
84
51
|
const onExit = p.exited.then(() => { exited = true; });
|
|
85
52
|
while (!exited) {
|
|
@@ -91,9 +58,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
91
58
|
}
|
|
92
59
|
await p.exited;
|
|
93
60
|
await onExit;
|
|
94
|
-
// restore promptly so the harvest's own output renders on a sane terminal;
|
|
95
|
-
// the finally's second restore is an idempotent stty and covers the
|
|
96
|
-
// thrown-mid-poll path.
|
|
97
61
|
restoreTermios(savedTermios);
|
|
98
62
|
|
|
99
63
|
const blobRaw = await readItem(iso);
|
|
@@ -111,7 +75,6 @@ export async function harvestIsolatedLogin(): Promise<HarvestedLogin | null> {
|
|
|
111
75
|
return null;
|
|
112
76
|
}
|
|
113
77
|
|
|
114
|
-
// Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
|
|
115
78
|
console.log(c.dim("sampling usage..."));
|
|
116
79
|
const sampled = await probeUsage(onboardDir);
|
|
117
80
|
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
package/src/cli/rename.ts
CHANGED
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing rename [--codex] <selector> <new-label>` - relabel a pooled
|
|
2
|
-
// account. The pools are separate namespaces and one email can hold both a
|
|
3
|
-
// claude and a codex account, so the codex pool is targeted explicitly via
|
|
4
|
-
// `--codex` (mirroring `switch --codex`), never by searching both pools.
|
|
5
|
-
|
|
6
1
|
import { withLock } from "../lib/lock.ts";
|
|
7
2
|
import { codexPaths, paths } from "../lib/paths.ts";
|
|
8
3
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
@@ -10,7 +5,6 @@ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
|
10
5
|
import { c } from "./render.ts";
|
|
11
6
|
import type { Account, CodexAccount } from "../lib/types.ts";
|
|
12
7
|
|
|
13
|
-
/** Resolve a claude account by email, label, or accountUuid prefix. */
|
|
14
8
|
export function findAccount(accounts: Account[], selector: string): Account | undefined {
|
|
15
9
|
const s = selector.toLowerCase();
|
|
16
10
|
return (
|
|
@@ -20,7 +14,6 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
20
14
|
);
|
|
21
15
|
}
|
|
22
16
|
|
|
23
|
-
/** Resolve a codex account by email, label, or accountId prefix. */
|
|
24
17
|
export function findCodexAccount(accounts: CodexAccount[], selector: string): CodexAccount | undefined {
|
|
25
18
|
const s = selector.toLowerCase();
|
|
26
19
|
return (
|
|
@@ -31,7 +24,6 @@ export function findCodexAccount(accounts: CodexAccount[], selector: string): Co
|
|
|
31
24
|
}
|
|
32
25
|
|
|
33
26
|
async function renameCodexAccount(input: { selector: string; newLabel: string }): Promise<number> {
|
|
34
|
-
// under the codex flock: a concurrent codex swap's index write must not be clobbered.
|
|
35
27
|
return withLock(codexPaths.lockFile, async () => {
|
|
36
28
|
const index = loadCodexAccounts();
|
|
37
29
|
const account = findCodexAccount(index.accounts, input.selector);
|
|
@@ -39,11 +31,6 @@ async function renameCodexAccount(input: { selector: string; newLabel: string })
|
|
|
39
31
|
console.error(c.red(`no codex account matches "${input.selector}"`));
|
|
40
32
|
return 1;
|
|
41
33
|
}
|
|
42
|
-
// labels resolve selectors first-match: a duplicate would make the other
|
|
43
|
-
// account unreachable by label and misdirect destructive commands like
|
|
44
|
-
// `rm` onto the wrong one (adversarial-review catch)
|
|
45
|
-
// case-insensitive, matching how findCodexAccount resolves selectors (PR
|
|
46
|
-
// #37 review catch: a casing-only duplicate slipped the === guard)
|
|
47
34
|
const taken = index.accounts.find((x) => x.accountId !== account.accountId && x.label.toLowerCase() === input.newLabel.toLowerCase());
|
|
48
35
|
if (taken) {
|
|
49
36
|
console.error(c.red(`label "${input.newLabel}" is already used by ${taken.accountId.slice(0, 8)} - labels must be unique within the pool`));
|
|
@@ -65,7 +52,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
|
|
|
65
52
|
return 2;
|
|
66
53
|
}
|
|
67
54
|
if (codex) return renameCodexAccount({ selector, newLabel });
|
|
68
|
-
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
69
55
|
return withLock(paths.lockFile, async () => {
|
|
70
56
|
const idx = loadAccounts();
|
|
71
57
|
const a = findAccount(idx.accounts, selector);
|
|
@@ -73,11 +59,6 @@ export async function cmdRename(argv: string[]): Promise<number> {
|
|
|
73
59
|
console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
|
|
74
60
|
return 1;
|
|
75
61
|
}
|
|
76
|
-
// labels resolve selectors first-match: a duplicate would make the other
|
|
77
|
-
// account unreachable by label and misdirect destructive commands like
|
|
78
|
-
// `rm` onto the wrong one (adversarial-review catch)
|
|
79
|
-
// case-insensitive, matching how findAccount resolves selectors (PR #37
|
|
80
|
-
// review catch: a casing-only duplicate slipped the === guard)
|
|
81
62
|
const taken = idx.accounts.find((x) => x.accountUuid !== a.accountUuid && x.label.toLowerCase() === newLabel.toLowerCase());
|
|
82
63
|
if (taken) {
|
|
83
64
|
console.error(c.red(`label "${newLabel}" is already used by ${taken.email} - labels must be unique within the pool`));
|
package/src/cli/render.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
// Terminal rendering helpers for the CLI (bars, colors, relative times).
|
|
2
|
-
|
|
3
1
|
import { clamp } from "es-toolkit";
|
|
4
2
|
|
|
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
3
|
export function makeColors(enabled: boolean) {
|
|
8
4
|
const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
|
|
9
5
|
return {
|
|
@@ -18,9 +14,6 @@ export function makeColors(enabled: boolean) {
|
|
|
18
14
|
|
|
19
15
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
20
16
|
|
|
21
|
-
/** Used% -> RGB on a continuous green->yellow->red ramp, anchored at the
|
|
22
|
-
* semantic severity bands (pure green at 0, yellow at 75, red from 95) so the
|
|
23
|
-
* band boundaries read exactly like the old 3-color scheme. Blue stays 0. */
|
|
24
17
|
function rampRgb(usedPct: number): { r: number; g: number } {
|
|
25
18
|
const u = clamp(usedPct, 0, 100);
|
|
26
19
|
if (u >= 95) return { r: 255, g: 0 };
|
|
@@ -28,11 +21,6 @@ function rampRgb(usedPct: number): { r: number; g: number } {
|
|
|
28
21
|
return { r: Math.round((255 * u) / 75), g: 255 };
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
/** Usage-severity painter for the statusLine (user decision 2026-07-18: colors
|
|
32
|
-
* carry quota status instead of numbers). Truecolor when the terminal
|
|
33
|
-
* advertises it, else the nearest 256-color cube step; disabled -> identity,
|
|
34
|
-
* and callers must then fall back to numeric rendering (without color a
|
|
35
|
-
* drained window must not look fresh). */
|
|
36
24
|
export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean }) {
|
|
37
25
|
return (usedPct: number) =>
|
|
38
26
|
(s: string): string => {
|
|
@@ -45,12 +33,6 @@ export function makeUsagePaint(input: { enabled: boolean; truecolor: boolean })
|
|
|
45
33
|
};
|
|
46
34
|
}
|
|
47
35
|
|
|
48
|
-
/** Plan label for a claude account, e.g. "max 20x": subscription name plus the
|
|
49
|
-
* multiplier segment of the rate-limit tier id ("default_claude_max_20x").
|
|
50
|
-
* Structural, never exact-string: the multiplier is any <digits>x segment, so
|
|
51
|
-
* tiers without one (e.g. "default_claude_zero") fall back to the bare
|
|
52
|
-
* subscription name, and an absent blob field falls back to null (unmeasured
|
|
53
|
-
* must not look like a tier). */
|
|
54
36
|
export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
|
|
55
37
|
const segments = input.rateLimitTier?.split("_") ?? [];
|
|
56
38
|
const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
|
|
@@ -58,12 +40,10 @@ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTie
|
|
|
58
40
|
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
59
41
|
}
|
|
60
42
|
|
|
61
|
-
/** "1 account" / "3 accounts": counted nouns always pluralize properly. */
|
|
62
43
|
export function count(input: { n: number; noun: string }): string {
|
|
63
44
|
return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
|
|
64
45
|
}
|
|
65
46
|
|
|
66
|
-
/** A fixed-width usage bar, colored by fill. */
|
|
67
47
|
export function bar(pct: number, width = 16): string {
|
|
68
48
|
const clamped = clamp(pct, 0, 100);
|
|
69
49
|
const filled = Math.round((clamped / 100) * width);
|
|
@@ -73,7 +53,6 @@ export function bar(pct: number, width = 16): string {
|
|
|
73
53
|
return `${paint(body)} ${label}`;
|
|
74
54
|
}
|
|
75
55
|
|
|
76
|
-
/** Relative-time string for a reset epoch, e.g. "resets in 2h13m". */
|
|
77
56
|
export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
|
|
78
57
|
if (epochMs == null) return "";
|
|
79
58
|
const dsec = Math.round((epochMs - now) / 1000);
|
|
@@ -85,8 +64,6 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
|
|
|
85
64
|
return `resets in ${m}m`;
|
|
86
65
|
}
|
|
87
66
|
|
|
88
|
-
/** Age of a cached sample, largest unit only: "just now", "3m ago", "2h ago",
|
|
89
|
-
* "5d ago". */
|
|
90
67
|
export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
91
68
|
const dsec = Math.max(0, Math.round((now - epochMs) / 1000));
|
|
92
69
|
if (dsec < 60) return "just now";
|
package/src/cli/rm.ts
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
|
-
|
|
3
1
|
import { rmSync } from "node:fs";
|
|
4
2
|
import { join } from "node:path";
|
|
5
3
|
import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
|
|
@@ -16,7 +14,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
16
14
|
console.error("usage: tokenmaxxing rm <email|label|uuid>");
|
|
17
15
|
return 2;
|
|
18
16
|
}
|
|
19
|
-
// under the flock: a concurrent swap's index write must not be clobbered.
|
|
20
17
|
return withLock(paths.lockFile, async () => {
|
|
21
18
|
const idx = loadAccounts();
|
|
22
19
|
const a = findAccount(idx.accounts, selector);
|
|
@@ -28,13 +25,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
28
25
|
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
29
26
|
return 1;
|
|
30
27
|
}
|
|
31
|
-
// The label drifts (manual /login); the token cannot lie. Removing the
|
|
32
|
-
// account whose credential is actually LIVE would destroy its only backup
|
|
33
|
-
// and leave the next swap refusing over an unpooled credential. Fail
|
|
34
|
-
// CLOSED (review catch, PR #31): when the live owner cannot be verified -
|
|
35
|
-
// unparsable blob, expired token, roles outage - refuse rather than trust
|
|
36
|
-
// the stale label; rm is destructive and can wait. The check is read-only:
|
|
37
|
-
// an expired bearer simply fails the roles call, nothing is ever rotated.
|
|
38
28
|
const live = await readItem(liveTarget());
|
|
39
29
|
if (live != null) {
|
|
40
30
|
let liveOrg: string;
|
|
@@ -51,15 +41,6 @@ export async function cmdRm(selector?: string): Promise<number> {
|
|
|
51
41
|
}
|
|
52
42
|
}
|
|
53
43
|
await deleteItem(parkedTarget(a.keychainItem));
|
|
54
|
-
// Sweep sample-probe residue too: a probe killed mid-run strands the
|
|
55
|
-
// account's isolated credential (macOS: a namespaced keychain item), and
|
|
56
|
-
// once the account leaves the pool nothing would ever probe-and-heal it
|
|
57
|
-
// again (closing-review critic gap). deleteItem on a missing item is a
|
|
58
|
-
// no-op. Hard delete, not trash, on purpose: the sample dir is a
|
|
59
|
-
// throwaway CLAUDE_CONFIG_DIR that can hold PLAINTEXT credential material
|
|
60
|
-
// on Linux - the credential-dir cleanup exception (owner 2026-07-16, same
|
|
61
|
-
// rule sample.ts and onboard.ts follow); trashing would move credentials
|
|
62
|
-
// into the Trash folder.
|
|
63
44
|
const sampleDir = join(paths.sampleDir, credItemFor(a.accountUuid));
|
|
64
45
|
await deleteItem(isolatedTarget(sampleDir));
|
|
65
46
|
rmSync(sampleDir, { recursive: true, force: true });
|
package/src/cli/status.ts
CHANGED
|
@@ -1,17 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing status`: accounts with 5h / weekly / per-model usage bars.
|
|
2
|
-
// Parked accounts are live-sampled in isolation (`claude -p /usage`); the active
|
|
3
|
-
// account is read off the free statusLine feed (usage.json) so we never poll its
|
|
4
|
-
// own busy token. A sample that fails falls back to the last-known values with a
|
|
5
|
-
// visible "(cached)" note - never a silent stale number. Fresh figures are
|
|
6
|
-
// persisted onto each account for the picker/switch logic.
|
|
7
|
-
//
|
|
8
|
-
// `--force` additionally PINGS every account (one minimal haiku request each)
|
|
9
|
-
// before sampling, so every account's 5h session window starts ticking NOW
|
|
10
|
-
// instead of lying dormant until first real use, and every bar is a live probe
|
|
11
|
-
// taken after the ping. The active account still prefers the tee only as a
|
|
12
|
-
// fallback: its own `/usage` fail-silents exactly when a live session is
|
|
13
|
-
// running it, and that session's tee is fresher than any cache.
|
|
14
|
-
|
|
15
1
|
import { sortBy } from "es-toolkit";
|
|
16
2
|
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
17
3
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -27,17 +13,11 @@ import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
|
|
|
27
13
|
import type { FullUsage } from "../lib/usage.ts";
|
|
28
14
|
import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
29
15
|
|
|
30
|
-
/** `preRender` runs after sampling, right before the first output line: `watch`
|
|
31
|
-
* keeps the previous frame on screen through the multi-second sample and
|
|
32
|
-
* clears only when the fresh frame is ready to paint (watch(1) semantics). */
|
|
33
16
|
export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
|
|
34
17
|
let idx = loadAccounts();
|
|
35
18
|
const cfg = loadConfig();
|
|
36
19
|
const now = Date.now();
|
|
37
20
|
|
|
38
|
-
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
39
|
-
// on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
|
|
40
|
-
// Fresh samples pass through unchanged (their resets are in the future).
|
|
41
21
|
const row = (name: string, w: UsageWindow, weekly: boolean) => {
|
|
42
22
|
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
43
23
|
const pct = passed ? 0 : w.usedPercentage;
|
|
@@ -46,10 +26,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
46
26
|
};
|
|
47
27
|
|
|
48
28
|
if (idx.accounts.length === 0) {
|
|
49
|
-
// A codex-only pool is a documented standalone flow: the empty-claude
|
|
50
|
-
// early return must still render it, and only a fully empty install gets
|
|
51
|
-
// the init hint (closing-review catch: bare `xx` claimed "no accounts"
|
|
52
|
-
// over a populated codex pool and pointed at the wrong init).
|
|
53
29
|
if (loadCodexAccounts().accounts.length > 0) {
|
|
54
30
|
console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
|
|
55
31
|
console.log();
|
|
@@ -60,9 +36,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
60
36
|
return 0;
|
|
61
37
|
}
|
|
62
38
|
|
|
63
|
-
// Load, sample, and save entirely under the flock: parked refreshes must not
|
|
64
|
-
// collide with an in-flight swap, and a save of an index loaded before a
|
|
65
|
-
// concurrent swap would clobber the swap's activeAccountUuid.
|
|
66
39
|
console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
|
|
67
40
|
const outcomes = new Map<string, SampleOutcome>();
|
|
68
41
|
await withLock(paths.lockFile, async () => {
|
|
@@ -72,19 +45,8 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
72
45
|
const liveOAuth = readOAuthAccount();
|
|
73
46
|
const activeOrg = liveOAuth?.organizationUuid ?? null;
|
|
74
47
|
const probeOne = async (a: Account) => {
|
|
75
|
-
// Active = the org claude's own oauthAccount names, NOT the stored
|
|
76
|
-
// label conjunction: after a manual /login the label lags, and routing
|
|
77
|
-
// the genuinely-live account through the parked prober would refresh
|
|
78
|
-
// its stale parked grant and could flag a healthy account needs-reauth.
|
|
79
|
-
// probeActiveUsage's own roles check still fail-fasts on residual drift.
|
|
80
48
|
const isActive = activeOrg != null && activeOrg === a.organizationUuid;
|
|
81
|
-
// The tee path never opens the credential blob, so the active account's
|
|
82
|
-
// tier comes from the live oauthAccount instead - it names this very org
|
|
83
|
-
// (uuid-matched above), so the tier is attributed to its own identity.
|
|
84
49
|
if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
|
|
85
|
-
// Active account: prefer the free statusLine push (usage.json) so we never
|
|
86
|
-
// poll its own token, which is busy exactly when it matters. per-model
|
|
87
|
-
// comes from model-usage.json (also statusLine-driven).
|
|
88
50
|
const fromStatusLine: FullUsage | null =
|
|
89
51
|
isActive && live && live.org === a.organizationUuid
|
|
90
52
|
? {
|
|
@@ -96,13 +58,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
96
58
|
let viaTee = false;
|
|
97
59
|
let outcome: SampleOutcome;
|
|
98
60
|
if (force) {
|
|
99
|
-
// Force: ping + live probe for everyone. The tee predates the ping,
|
|
100
|
-
// so it serves ONLY as the fallback for the fail-silent `/usage`
|
|
101
|
-
// case (the probe got past every guard and claude printed no limit
|
|
102
|
-
// lines). Any other failure - identity mismatch, dead refresh, a
|
|
103
|
-
// refused ping - must stay VISIBLE: the old unconditional fallback
|
|
104
|
-
// masked pre-ping failures as healthy tee data, silently skipping
|
|
105
|
-
// the ping with no warning (closing-review catch).
|
|
106
61
|
outcome = isActive ? await probeActiveUsage(a, { ping: true }) : await probeParkedUsage(a, { ping: true });
|
|
107
62
|
if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
|
|
108
63
|
const failed = outcome;
|
|
@@ -121,35 +76,17 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
121
76
|
outcomes.set(a.accountUuid, outcome);
|
|
122
77
|
if (!outcome.ok) return;
|
|
123
78
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
124
|
-
// stamp when the figures were actually measured: the statusLine tee's
|
|
125
|
-
// own write time for the push-fed active account, else the probe time.
|
|
126
79
|
a.lastUsageAt = viaTee && live ? live.ts : Date.now();
|
|
127
80
|
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
128
81
|
a.lastPerModel = outcome.usage.perModel;
|
|
129
|
-
// via the tee, the per-model rows come from model-usage.json whose
|
|
130
|
-
// own sample time may be DAYS older than the aggregate tee - dating
|
|
131
|
-
// them by the tee's timestamp inflated the null-reset self-bound
|
|
132
|
-
// (closing-review catch). A fresh probe's rows date by the probe.
|
|
133
82
|
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
134
83
|
}
|
|
135
84
|
};
|
|
136
|
-
// The ACTIVE probe runs FIRST, alone: it refreshes an expired live access
|
|
137
|
-
// token under claude's refresh lock, and every parked probe's fail-closed
|
|
138
|
-
// live-owner check reads that same live token - run concurrently they
|
|
139
|
-
// raced the refresh and, after the machine idled past the token lifetime,
|
|
140
|
-
// every parked sample 401'd on the first `xx status` (closing-review
|
|
141
|
-
// catch). Parked probes then run concurrently as before.
|
|
142
85
|
const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
|
|
143
86
|
if (activeAccount) await probeOne(activeAccount);
|
|
144
|
-
// even when the active account's usage came from the TEE (no active
|
|
145
|
-
// probe, no refresh side effect), the parked probes still need a fresh
|
|
146
|
-
// live token for their fail-closed live-owner checks (cubic review
|
|
147
|
-
// catch, PR #35). A dead live grant is deliberately not fatal here: each
|
|
148
|
-
// parked probe reports its own honest failure reason.
|
|
149
87
|
try {
|
|
150
88
|
await ensureLiveTokenFresh();
|
|
151
89
|
} catch {
|
|
152
|
-
// per-account guards surface the failure per probe
|
|
153
90
|
}
|
|
154
91
|
await Promise.all(idx.accounts.filter((a) => a !== activeAccount).map(probeOne));
|
|
155
92
|
saveAccounts(idx);
|
|
@@ -159,18 +96,12 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
159
96
|
console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
|
|
160
97
|
console.log();
|
|
161
98
|
|
|
162
|
-
// Display order (user decision 2026-07-18): earliest upcoming reset first
|
|
163
|
-
// (5h or extrapolated weekly, from the just-refreshed samples), needs-reauth
|
|
164
|
-
// last; the ● marker identifies the active account wherever it sorts.
|
|
165
99
|
const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
|
|
166
|
-
// The marker matches the probe routing above: active = the org claude's own
|
|
167
|
-
// oauthAccount names (the swap label can lag after a manual /login).
|
|
168
100
|
const displayActiveOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
169
101
|
for (const a of displayAccounts) {
|
|
170
102
|
const active = displayActiveOrg != null && a.organizationUuid === displayActiveOrg;
|
|
171
103
|
const outcome = outcomes.get(a.accountUuid);
|
|
172
104
|
const failed = outcome ? !outcome.ok : false;
|
|
173
|
-
// On a failed sample, fall back to the last-known values (with a note below).
|
|
174
105
|
const usage = outcome?.ok ? outcome.usage : undefined;
|
|
175
106
|
const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
|
|
176
107
|
const perModel = usage ? usage.perModel : a.lastPerModel;
|
|
@@ -184,8 +115,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
184
115
|
|
|
185
116
|
const tier = claudeTierLabel(a);
|
|
186
117
|
console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
187
|
-
// Chart label convention (user rule 2026-07-17): everything lowercase,
|
|
188
|
-
// model names short ("fable", "spark").
|
|
189
118
|
if (aggregate) {
|
|
190
119
|
row("5h", aggregate.fiveHour, false);
|
|
191
120
|
row("week", aggregate.sevenDay, true);
|
|
@@ -198,10 +127,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
198
127
|
if (outcome?.pingError != null) {
|
|
199
128
|
console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(outcome.pingError)}`);
|
|
200
129
|
}
|
|
201
|
-
// A successful ping ALWAYS opens the 5h window (live-verified 2026-07-16),
|
|
202
|
-
// but the server's usage feed reflects it with a lag of up to a few
|
|
203
|
-
// minutes, so a probe taken seconds later can still show a dormant window.
|
|
204
|
-
// Say so rather than looking like the ping did nothing.
|
|
205
130
|
if (force && outcome?.ok && outcome.pingError == null && aggregate && aggregate.fiveHour.resetsAt == null) {
|
|
206
131
|
console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
|
|
207
132
|
}
|
|
@@ -212,9 +137,6 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
212
137
|
return 0;
|
|
213
138
|
}
|
|
214
139
|
|
|
215
|
-
/** The codex pool, appended when it is non-empty. Every account samples via
|
|
216
|
-
* the free usage GET (nothing metered, no window started): the live account
|
|
217
|
-
* through the live auth.json, parked accounts through their parked blobs. */
|
|
218
140
|
async function renderCodexSection(input: {
|
|
219
141
|
cfg: Config;
|
|
220
142
|
now: number;
|
|
@@ -251,8 +173,6 @@ async function renderCodexSection(input: {
|
|
|
251
173
|
console.log();
|
|
252
174
|
const windowLabel = (window: CodexWindow) =>
|
|
253
175
|
isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
|
|
254
|
-
// Same display order as the claude pool: earliest upcoming reset first
|
|
255
|
-
// across every cached window, needs-reauth last.
|
|
256
176
|
const displayAccounts = sortBy(index.accounts, [
|
|
257
177
|
(a) => (a.needsReauth ? 1 : 0),
|
|
258
178
|
(a) => {
|
package/src/cli/switch.ts
CHANGED
|
@@ -1,19 +1,3 @@
|
|
|
1
|
-
// `tokenmaxxing switch [selector]`.
|
|
2
|
-
// No selector → greedy: rank EVERY account (current included) by pace pressure
|
|
3
|
-
// (furthest behind its own weekly pace first, see picker.ts) among those with
|
|
4
|
-
// session/week under threshold, off the cached windows.
|
|
5
|
-
// When the current account already wins (or ties - swapping between equals buys
|
|
6
|
-
// nothing), do nothing: the command is idempotent, so running it periodically
|
|
7
|
-
// converges on the right account. With a selector → switch to that one. Runs
|
|
8
|
-
// under the flock. When everything is depleted it stays on / switches to
|
|
9
|
-
// whichever account recovers soonest.
|
|
10
|
-
//
|
|
11
|
-
// The active LABEL can drift from the live login (manual /login is the
|
|
12
|
-
// surviving drift source). A no-op would freeze that drift forever, so when
|
|
13
|
-
// ~/.claude.json names a different account than accounts.json we swap for real
|
|
14
|
-
// instead of trusting the label - performSwap resolves the live credential's
|
|
15
|
-
// true owner, harvesting the drifted login correctly.
|
|
16
|
-
|
|
17
1
|
import { withLock } from "../lib/lock.ts";
|
|
18
2
|
import { paths } from "../lib/paths.ts";
|
|
19
3
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
@@ -36,10 +20,6 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
36
20
|
|
|
37
21
|
return withLock(paths.lockFile, async () => {
|
|
38
22
|
const idx = loadAccounts();
|
|
39
|
-
// ONE claude.json read for both identity fields: two reads could straddle
|
|
40
|
-
// a concurrent /login and describe two different live identities - the
|
|
41
|
-
// drift check and the seat must come from the same snapshot (cubic review
|
|
42
|
-
// catch, PR #33).
|
|
43
23
|
const liveClaim = readOAuthAccount();
|
|
44
24
|
const claimed = liveClaim?.accountUuid ?? null;
|
|
45
25
|
const claimedOrg = liveClaim?.organizationUuid ?? null;
|
|
@@ -64,26 +44,9 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
64
44
|
return swapTo(target);
|
|
65
45
|
}
|
|
66
46
|
|
|
67
|
-
// auto: greedy over everyone, current included - a no-op when current wins.
|
|
68
|
-
// No session context here, so every configured per-model family gates.
|
|
69
|
-
// Dead-token fallback mirrors decide.ts's greedy loop, NOT chooseAndSwap:
|
|
70
|
-
// its fallback lands on the next usable candidate unconditionally, which
|
|
71
|
-
// is right when over a bar but wrong here - a dead grant on the pace
|
|
72
|
-
// winner must re-check the seat, or a healthy current account gets a real
|
|
73
|
-
// swap onto a pace-WORSE account and the next periodic check bounces it
|
|
74
|
-
// straight back (closing-review catch). performSwap persists needs-reauth
|
|
75
|
-
// before throwing, so each reload shrinks the candidate set and the loop
|
|
76
|
-
// terminates.
|
|
77
47
|
const everyone: PickCtx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
|
|
78
48
|
while (true) {
|
|
79
49
|
const cur = loadAccounts();
|
|
80
|
-
// The seat is the LIVE login's pooled account when resolvable, the
|
|
81
|
-
// stored label only as fallback - the same identity rule AND the same
|
|
82
|
-
// identity KEY as decide.ts's seatOf: the organizationUuid, since quota
|
|
83
|
-
// is metered per org and the org is what the roles endpoint verifies
|
|
84
|
-
// (bugbot review catches, PR #33). Under drift this also makes ties
|
|
85
|
-
// favor the live account: the realign swap then keeps the same
|
|
86
|
-
// credential instead of hopping accounts on a tie.
|
|
87
50
|
const active =
|
|
88
51
|
(claimedOrg != null ? cur.accounts.find((a) => a.organizationUuid === claimedOrg) : null) ??
|
|
89
52
|
cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
|
|
@@ -96,7 +59,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
96
59
|
return 0;
|
|
97
60
|
}
|
|
98
61
|
const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
|
|
99
|
-
if (!best) break;
|
|
62
|
+
if (!best) break;
|
|
100
63
|
try {
|
|
101
64
|
await performSwap(best);
|
|
102
65
|
} catch (e) {
|
|
@@ -110,20 +73,11 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
110
73
|
return 0;
|
|
111
74
|
}
|
|
112
75
|
|
|
113
|
-
// No usable target swapped in: everything is depleted, or the remaining
|
|
114
|
-
// candidates' refresh tokens just died (performSwap persists needs-reauth
|
|
115
|
-
// before throwing, hence the reload). Stay on / switch to whichever
|
|
116
|
-
// recovers soonest. (Layer 2 - the wall squeeze - is deliberately confined
|
|
117
|
-
// to the automatic decision path in decide.ts, which decides off the live
|
|
118
|
-
// statusLine tee; bare `xx switch` stays cache-only and simply parks here.)
|
|
119
76
|
const fresh = loadAccounts();
|
|
120
77
|
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
121
78
|
if (!earliest) {
|
|
122
|
-
// Either every account needs re-auth, or every account is blocked with no
|
|
123
|
-
// recoverable bound (unparsed reset clocks AND no sample time - see log).
|
|
124
79
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
125
80
|
if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
|
|
126
|
-
// never freeze a label drift behind a no-op (see header).
|
|
127
81
|
const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
|
|
128
82
|
if (drifted && freshActive) return swapTo(freshActive);
|
|
129
83
|
console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
|
|
@@ -132,8 +86,6 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
132
86
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
133
87
|
const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
|
|
134
88
|
if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
|
|
135
|
-
// availableAt in the past means the current account is fine and the
|
|
136
|
-
// others are simply unusable - do not claim a limit that is not there.
|
|
137
89
|
const msg = earliest.availableAt <= now
|
|
138
90
|
? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
|
|
139
91
|
: `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
|
package/src/cli/watch.ts
CHANGED
|
@@ -1,23 +1,12 @@
|
|
|
1
|
-
// `tokenmaxxing watch [seconds]`: native live status (issue #3) - re-render
|
|
2
|
-
// `status` on an interval instead of `watch -n 120 'tokenmaxxing status'`.
|
|
3
|
-
// Plain `status` per tick, never `--force`: a ping meters real quota and starts
|
|
4
|
-
// 5h session windows, so watching must stay free; each refresh costs only the
|
|
5
|
-
// parked accounts' `/usage` probes the one-shot status already does.
|
|
6
|
-
|
|
7
1
|
import { clamp, delay } from "es-toolkit";
|
|
8
2
|
import { loadAccounts } from "../lib/state.ts";
|
|
9
3
|
import { cmdStatus } from "./status.ts";
|
|
10
4
|
import { c } from "./render.ts";
|
|
11
5
|
|
|
12
6
|
const DEFAULT_INTERVAL_S = 120;
|
|
13
|
-
/** Floor: each tick spawns a `/usage` probe per parked account (seconds each,
|
|
14
|
-
* under the flock); anything faster than this just queues probes. */
|
|
15
7
|
const MIN_INTERVAL_S = 30;
|
|
16
|
-
/** Cap: past ~24.8 days the setTimeout ms overflow collapses the sleep to ~1ms
|
|
17
|
-
* and the loop runs hot; a day is already beyond any sane watch cadence. */
|
|
18
8
|
const MAX_INTERVAL_S = 86_400;
|
|
19
9
|
|
|
20
|
-
/** Seconds between refreshes, clamped; null when the argument is not a positive number. */
|
|
21
10
|
export function resolveWatchInterval(arg?: string): number | null {
|
|
22
11
|
if (arg === undefined) return DEFAULT_INTERVAL_S;
|
|
23
12
|
const n = Number(arg);
|
|
@@ -25,9 +14,6 @@ export function resolveWatchInterval(arg?: string): number | null {
|
|
|
25
14
|
return clamp(n, MIN_INTERVAL_S, MAX_INTERVAL_S);
|
|
26
15
|
}
|
|
27
16
|
|
|
28
|
-
/** Home + clear screen + clear scrollback. Written only once the fresh frame is
|
|
29
|
-
* ready to paint (cmdStatus preRender), so the previous frame stays readable
|
|
30
|
-
* through the multi-second sample instead of blanking - watch(1) semantics. */
|
|
31
17
|
const CLEAR = "\x1b[H\x1b[2J\x1b[3J";
|
|
32
18
|
|
|
33
19
|
export async function cmdWatch(intervalArg?: string): Promise<number> {
|
|
@@ -43,9 +29,6 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
|
|
|
43
29
|
console.log(c.dim(`watch: every ${intervalS}s, ${new Date().toLocaleTimeString()}, ctrl-c to quit`));
|
|
44
30
|
};
|
|
45
31
|
while (true) {
|
|
46
|
-
// One failed tick (a mid-write ~/.claude.json read, a transient probe or
|
|
47
|
-
// lock error) must not kill an hours-long monitor: report it and keep the
|
|
48
|
-
// cadence, exactly like watch(1) showing a failing command's output.
|
|
49
32
|
try {
|
|
50
33
|
await cmdStatus(false, paintHeader);
|
|
51
34
|
} catch (e) {
|