tokenmaxxing 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +2 -2
- package/README.md +1 -1
- package/agent-plugin/plugin.json +8 -2
- package/package.json +1 -1
- package/src/cli/add.ts +1 -8
- package/src/cli/auth.ts +0 -23
- package/src/cli/check.ts +19 -13
- package/src/cli/codexadd.ts +0 -17
- package/src/cli/codexinit.ts +11 -42
- package/src/cli/codexrm.ts +0 -13
- package/src/cli/codexswitch.ts +0 -15
- package/src/cli/config.ts +0 -30
- package/src/cli/doctor.ts +1 -14
- package/src/cli/init.ts +8 -34
- package/src/cli/ls.ts +0 -2
- package/src/cli/onboard.ts +0 -37
- package/src/cli/rename.ts +0 -19
- package/src/cli/render.ts +0 -23
- package/src/cli/rm.ts +0 -19
- package/src/cli/status.ts +0 -80
- package/src/cli/switch.ts +1 -49
- package/src/cli/watch.ts +0 -17
- package/src/entries/codexstophook.ts +2 -63
- package/src/entries/codexsupervisor.ts +1 -67
- package/src/entries/mcp.ts +0 -11
- package/src/entries/sessionstart.ts +1 -8
- package/src/entries/statusline.ts +0 -66
- package/src/entries/stopfailurehook.ts +93 -0
- package/src/entries/stophook.ts +3 -23
- package/src/entries/subagentstatusline.ts +0 -19
- package/src/entries/supervisor.ts +32 -132
- package/src/lib/atomic.ts +0 -16
- package/src/lib/claudebin.ts +4 -55
- package/src/lib/claudejson.ts +0 -10
- package/src/lib/claudelock.ts +13 -35
- package/src/lib/codexauth.ts +0 -29
- package/src/lib/codexbin.ts +0 -10
- package/src/lib/codexdecide.ts +1 -112
- package/src/lib/codexoauth.ts +0 -16
- package/src/lib/codexpick.ts +0 -31
- package/src/lib/codexpresence.ts +0 -35
- package/src/lib/codexsample.ts +0 -23
- package/src/lib/codexstate.ts +0 -7
- package/src/lib/codexswap.ts +0 -32
- package/src/lib/codexusage.ts +0 -28
- package/src/lib/credstore.ts +0 -24
- package/src/lib/decide.ts +127 -180
- package/src/lib/http.ts +0 -9
- package/src/lib/install.ts +57 -124
- package/src/lib/keychain.ts +1 -39
- package/src/lib/lock.ts +0 -24
- package/src/lib/log.ts +0 -14
- package/src/lib/oauth.ts +1 -31
- package/src/lib/paths.ts +1 -45
- package/src/lib/picker.ts +1 -84
- package/src/lib/proc.ts +0 -17
- package/src/lib/sample.ts +0 -68
- package/src/lib/sessions.ts +0 -13
- package/src/lib/settings.ts +15 -42
- package/src/lib/state.ts +23 -77
- package/src/lib/swap.ts +3 -87
- package/src/lib/tty.ts +0 -4
- package/src/lib/types.ts +13 -140
- package/src/lib/usage.ts +108 -196
- package/src/lib/worktree.ts +0 -8
- package/src/main.ts +5 -35
- package/src/sdk.ts +0 -59
package/src/lib/install.ts
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
|
-
// depends on argv0 semantics.
|
|
4
|
-
|
|
5
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
1
|
+
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
2
|
import { basename, dirname, join } from "node:path";
|
|
7
3
|
import { escape } from "es-toolkit";
|
|
8
4
|
import { z } from "zod";
|
|
@@ -19,7 +15,6 @@ const InstallOutcomeSchema = z.object({
|
|
|
19
15
|
});
|
|
20
16
|
export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
|
|
21
17
|
|
|
22
|
-
/** True if our binDir comes before the real claude's dir on PATH. */
|
|
23
18
|
export function isBinDirAhead(): boolean {
|
|
24
19
|
const dirs = (process.env.PATH ?? "").split(":");
|
|
25
20
|
const ourIdx = dirs.indexOf(paths.binDir);
|
|
@@ -33,12 +28,8 @@ export function isBinDirAhead(): boolean {
|
|
|
33
28
|
}
|
|
34
29
|
}
|
|
35
30
|
|
|
36
|
-
// Optional "1"/"true"/"yes" flag; unset/empty → undefined (feature off).
|
|
37
31
|
const EnvFlagSchema = z.enum(["1", "true", "yes"]).optional().catch(undefined);
|
|
38
32
|
|
|
39
|
-
/** True when this process is the Nix-packaged CLI (flake startScript sets
|
|
40
|
-
* TOKENMAXXING_NIX=1; store-path Bun.main is the fallback for wraps that
|
|
41
|
-
* forget the env). Env overrides parse at the read site. */
|
|
42
33
|
export function isNixPackaged(): boolean {
|
|
43
34
|
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_NIX) != null) return true;
|
|
44
35
|
try {
|
|
@@ -48,17 +39,38 @@ export function isNixPackaged(): boolean {
|
|
|
48
39
|
}
|
|
49
40
|
}
|
|
50
41
|
|
|
51
|
-
/** True when a Nix module owns the periodic check timer; init must not write
|
|
52
|
-
* a second imperative unit. */
|
|
53
42
|
export function skipImperativeTimer(): boolean {
|
|
54
43
|
return EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_TIMER) != null;
|
|
55
44
|
}
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
46
|
+
function isNixStorePath(path: string): boolean {
|
|
47
|
+
return path === "/nix/store" || path.startsWith("/nix/store/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function isEacces(e: unknown): boolean {
|
|
51
|
+
return typeof e === "object" && e != null && "code" in e && e.code === "EACCES";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cannotWriteRcTarget(target: string): boolean {
|
|
55
|
+
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_SHELL_RC) != null) return true;
|
|
56
|
+
if (isNixStorePath(target)) return true;
|
|
57
|
+
if (!existsSync(target)) return false;
|
|
58
|
+
try {
|
|
59
|
+
accessSync(target, constants.W_OK);
|
|
60
|
+
return false;
|
|
61
|
+
} catch {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function managedShellRcSkipLines(): { headline: string; detail: string; exportLine: string } {
|
|
67
|
+
return {
|
|
68
|
+
headline: "shell rc is managed (Home Manager / nix-store) - PATH was not auto-edited",
|
|
69
|
+
detail: `put ${paths.binDir} on PATH via home.sessionPath (programs.tokenmaxxing Home Manager module sets this), e.g.`,
|
|
70
|
+
exportLine: `home.sessionPath = [ "${paths.binDir}" ];`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
function nixSupervisorShim(bun: string, entry: string): string {
|
|
63
75
|
return `#!/bin/sh
|
|
64
76
|
dir=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
|
@@ -81,11 +93,7 @@ exec ${JSON.stringify(bun)} run ${JSON.stringify(entry)} "$@"
|
|
|
81
93
|
|
|
82
94
|
export function installSupervisor(): InstallOutcome {
|
|
83
95
|
mkdirSync(paths.binDir, { recursive: true });
|
|
84
|
-
const target = installedBin();
|
|
85
|
-
// Resolve the entry through the global-bin symlink (bun add -g links
|
|
86
|
-
// ~/.bun/bin/tokenmaxxing → the package's src/main.ts) so the shim points
|
|
87
|
-
// into the installed package tree, where its imports resolve. Nix shims
|
|
88
|
-
// prefer PATH first (see nixSupervisorShim).
|
|
96
|
+
const target = installedBin();
|
|
89
97
|
const entry = realpathSync(Bun.main);
|
|
90
98
|
if (isNixPackaged()) {
|
|
91
99
|
writeFileAtomic(target, nixSupervisorShim(process.execPath, entry), 0o755);
|
|
@@ -93,9 +101,7 @@ export function installSupervisor(): InstallOutcome {
|
|
|
93
101
|
writeFileAtomic(target, `#!/bin/sh\nexec ${JSON.stringify(process.execPath)} run ${JSON.stringify(entry)} "$@"\n`, 0o755);
|
|
94
102
|
}
|
|
95
103
|
|
|
96
|
-
// the on-PATH `claude` wrapper
|
|
97
104
|
writeFileAtomic(paths.supervisorLink, `#!/bin/sh\nexec ${JSON.stringify(target)} __supervise "$@"\n`, 0o755);
|
|
98
|
-
// the `xx` short alias → tokenmaxxing
|
|
99
105
|
writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
|
|
100
106
|
|
|
101
107
|
installSettings();
|
|
@@ -107,14 +113,6 @@ export function installSupervisor(): InstallOutcome {
|
|
|
107
113
|
};
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
// ---- codex supervisor + Stop hook -------------------------------------------
|
|
111
|
-
|
|
112
|
-
/** Codex hook declarations we merge into. The FILE nests the event map under a
|
|
113
|
-
* `hooks` field (`struct HooksFile { description?, hooks }`, binary-verified
|
|
114
|
-
* 0.144.4 after a live parse failure proved the docs' claude-style top-level
|
|
115
|
-
* event map wrong: "unknown field Stop, expected description or hooks").
|
|
116
|
-
* Loose everywhere: every other event and every foreign Stop entry rides
|
|
117
|
-
* along verbatim. */
|
|
118
116
|
const CodexHookEventsSchema = z.looseObject({
|
|
119
117
|
Stop: z.array(z.looseObject({ hooks: z.array(z.looseObject({ command: z.string().optional() })).default([]) })).default([]),
|
|
120
118
|
});
|
|
@@ -126,20 +124,9 @@ const CodexHooksFileSchema = z.looseObject({
|
|
|
126
124
|
const CODEX_STOP_HOOK_SUBCOMMAND = "__codex-stop-hook";
|
|
127
125
|
|
|
128
126
|
function codexStopHookCommand(): string {
|
|
129
|
-
// Quoted like the claude shim commands: an install path with a space would
|
|
130
|
-
// otherwise mis-split and the hook would silently never run.
|
|
131
127
|
return `${JSON.stringify(installedBin())} ${CODEX_STOP_HOOK_SUBCOMMAND}`;
|
|
132
128
|
}
|
|
133
129
|
|
|
134
|
-
/** Idempotently install the tokenmaxxing Stop entry in ~/.codex/hooks.json,
|
|
135
|
-
* preserving every other declaration. Codex skips new hooks until the user
|
|
136
|
-
* trusts them via /hooks (trust is recorded against the hook's hash), so the
|
|
137
|
-
* caller must surface that step. */
|
|
138
|
-
/** Surgical WITHIN groups, ownership verified structurally (closing-review
|
|
139
|
-
* catch, mirroring settings.ts's removeHook fix): the old whole-group filter
|
|
140
|
-
* deleted a foreign hook the user had appended into our group - the natural
|
|
141
|
-
* edit, since install writes exactly one group - and its includes() match
|
|
142
|
-
* claimed any command merely mentioning the subcommand. */
|
|
143
130
|
function withoutOurCodexStopHooks(groups: { hooks: { type?: string; command?: string }[] }[]): typeof groups {
|
|
144
131
|
return groups
|
|
145
132
|
.map((group) => ({ ...group, hooks: group.hooks.filter((hook) => !isOurHookCommand(hook.command ?? "", CODEX_STOP_HOOK_SUBCOMMAND)) }))
|
|
@@ -181,7 +168,6 @@ export function codexSupervisorLink(): string {
|
|
|
181
168
|
return join(paths.binDir, "codex");
|
|
182
169
|
}
|
|
183
170
|
|
|
184
|
-
/** The on-PATH `codex` wrapper + the Stop hook declaration. */
|
|
185
171
|
export function installCodexSupervisor(): void {
|
|
186
172
|
mkdirSync(paths.binDir, { recursive: true });
|
|
187
173
|
writeFileAtomic(codexSupervisorLink(), `#!/bin/sh\nexec ${JSON.stringify(installedBin())} __supervise-codex "$@"\n`, 0o755);
|
|
@@ -193,26 +179,18 @@ export function uninstallCodexSupervisor(): void {
|
|
|
193
179
|
if (existsSync(codexSupervisorLink())) rmSync(codexSupervisorLink(), { force: true });
|
|
194
180
|
}
|
|
195
181
|
|
|
196
|
-
|
|
197
|
-
// The hooks evaluate only at turn boundaries; one long agentic turn can burn a
|
|
198
|
-
// window from healthy to depleted with zero boundaries (2026-07-10 incident).
|
|
199
|
-
// A timer closes that gap: launchd on macOS, a systemd user timer on Linux.
|
|
200
|
-
|
|
201
|
-
const CHECK_INTERVAL_S = 180;
|
|
182
|
+
const CHECK_INTERVAL_S = 60;
|
|
202
183
|
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
203
184
|
|
|
204
185
|
function launchdPlist(): string {
|
|
205
186
|
return join(paths.launchdAgentsDir, `${LAUNCHD_LABEL}.plist`);
|
|
206
187
|
}
|
|
207
188
|
|
|
208
|
-
/** `gui/<uid>` launchd domain, or null when the platform has no getuid. */
|
|
209
189
|
function launchdDomain(): string | null {
|
|
210
190
|
const uid = process.getuid?.();
|
|
211
191
|
return uid == null ? null : `gui/${uid}`;
|
|
212
192
|
}
|
|
213
193
|
|
|
214
|
-
/** launchctl/systemctl may be absent (spawnSync throws ENOENT) or hang on a
|
|
215
|
-
* dead session bus (ssh without lingering) - degrade, never crash or block. */
|
|
216
194
|
function run(cmd: string[]): boolean {
|
|
217
195
|
try {
|
|
218
196
|
return Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore", timeout: 10_000 }).exitCode === 0;
|
|
@@ -221,12 +199,7 @@ function run(cmd: string[]): boolean {
|
|
|
221
199
|
}
|
|
222
200
|
}
|
|
223
201
|
|
|
224
|
-
/** Install + activate the periodic check job. False means the unit files are in
|
|
225
|
-
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
226
|
-
* the caller prints the manual activation step. */
|
|
227
202
|
function installCheckTimer(): boolean {
|
|
228
|
-
// Nix module owns the timer (TOKENMAXXING_SKIP_TIMER): do not write a second
|
|
229
|
-
// unit that would double-fire or clobber the declarative one.
|
|
230
203
|
if (skipImperativeTimer()) return true;
|
|
231
204
|
|
|
232
205
|
if (process.platform === "darwin") {
|
|
@@ -238,7 +211,7 @@ function installCheckTimer(): boolean {
|
|
|
238
211
|
<plist version="1.0">
|
|
239
212
|
<dict>
|
|
240
213
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
241
|
-
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
214
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string><string>--if-due</string></array>
|
|
242
215
|
<key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
|
|
243
216
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
244
217
|
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
@@ -249,13 +222,11 @@ function installCheckTimer(): boolean {
|
|
|
249
222
|
);
|
|
250
223
|
const domain = launchdDomain();
|
|
251
224
|
if (domain == null) return false;
|
|
252
|
-
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
253
|
-
// bootstrap can lose a benign race with an in-flight bootout; loaded is loaded.
|
|
225
|
+
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
254
226
|
return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
|
|
255
227
|
}
|
|
256
228
|
|
|
257
|
-
|
|
258
|
-
const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
|
|
229
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" check --if-due`;
|
|
259
230
|
writeFileAtomic(
|
|
260
231
|
join(paths.systemdUserDir, "tokenmaxxing-check.service"),
|
|
261
232
|
`[Unit]
|
|
@@ -275,7 +246,7 @@ Description=tokenmaxxing periodic account-switch check
|
|
|
275
246
|
[Timer]
|
|
276
247
|
OnBootSec=60
|
|
277
248
|
OnUnitActiveSec=${CHECK_INTERVAL_S}
|
|
278
|
-
AccuracySec=
|
|
249
|
+
AccuracySec=5
|
|
279
250
|
|
|
280
251
|
[Install]
|
|
281
252
|
WantedBy=timers.target
|
|
@@ -288,7 +259,6 @@ WantedBy=timers.target
|
|
|
288
259
|
);
|
|
289
260
|
}
|
|
290
261
|
|
|
291
|
-
/** The manual activation command for an unloaded timer, per platform. */
|
|
292
262
|
export function timerActivationHint(): string {
|
|
293
263
|
if (process.platform === "darwin") {
|
|
294
264
|
return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
|
|
@@ -296,10 +266,7 @@ export function timerActivationHint(): string {
|
|
|
296
266
|
return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
|
|
297
267
|
}
|
|
298
268
|
|
|
299
|
-
/** True when the timer unit exists AND the service manager reports it loaded. */
|
|
300
269
|
export function checkTimerHealthy(): boolean {
|
|
301
|
-
// Declarative Nix timer: init wrote nothing; doctor must not demand the
|
|
302
|
-
// imperative unit.
|
|
303
270
|
if (skipImperativeTimer()) return true;
|
|
304
271
|
if (process.platform === "darwin") {
|
|
305
272
|
const domain = launchdDomain();
|
|
@@ -311,7 +278,6 @@ export function checkTimerHealthy(): boolean {
|
|
|
311
278
|
);
|
|
312
279
|
}
|
|
313
280
|
|
|
314
|
-
/** The manual deactivation command for a still-loaded timer, per platform. */
|
|
315
281
|
export function timerDeactivationHint(): string {
|
|
316
282
|
if (process.platform === "darwin") {
|
|
317
283
|
return `launchctl bootout gui/$(id -u)/${LAUNCHD_LABEL}`;
|
|
@@ -319,10 +285,6 @@ export function timerDeactivationHint(): string {
|
|
|
319
285
|
return "systemctl --user disable --now tokenmaxxing-check.timer";
|
|
320
286
|
}
|
|
321
287
|
|
|
322
|
-
/** Is the launchd check job loaded? Exit contract verified on this machine
|
|
323
|
-
* (macOS 26, 2026-07-20): `launchctl print` exits 0 for a loaded job and 113
|
|
324
|
-
* for a missing one. Anything else - including a spawn failure or timeout -
|
|
325
|
-
* is "unavailable": an unanswerable probe must never read as "not loaded". */
|
|
326
288
|
function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
327
289
|
const domain = launchdDomain();
|
|
328
290
|
if (domain == null) return "unavailable";
|
|
@@ -335,15 +297,6 @@ function launchdJobLoaded(): "loaded" | "not-loaded" | "unavailable" {
|
|
|
335
297
|
}
|
|
336
298
|
}
|
|
337
299
|
|
|
338
|
-
/** Is the systemd check timer active? Classification goes by the state
|
|
339
|
-
* string, never the exit code: systemctl(1) documents only "0 if at least
|
|
340
|
-
* one is active, non-zero otherwise" for is-active, but guarantees "unless
|
|
341
|
-
* --quiet is specified, this will also print the current unit state to
|
|
342
|
-
* standard output" (verified against the systemd manpage 2026-07-20). The
|
|
343
|
-
* failure mode is container-verified (ubuntu:24.04 systemd, no user bus,
|
|
344
|
-
* 2026-07-20): a dead session bus prints NOTHING to stdout ("Failed to
|
|
345
|
-
* connect to bus" goes to stderr, exit 1), so an empty or unrecognized
|
|
346
|
-
* stdout is "unavailable" - "cannot ask" never reads as "inactive". */
|
|
347
300
|
function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
348
301
|
try {
|
|
349
302
|
const proc = Bun.spawnSync(["systemctl", "--user", "is-active", "tokenmaxxing-check.timer"], { stdout: "pipe", stderr: "ignore", timeout: 10_000 });
|
|
@@ -356,14 +309,7 @@ function systemdTimerActive(): "active" | "not-active" | "unavailable" {
|
|
|
356
309
|
}
|
|
357
310
|
}
|
|
358
311
|
|
|
359
|
-
/** True when the job is verifiably no longer loaded. A swallowed bootout
|
|
360
|
-
* failure once meant a half-uninstalled state kept firing `tokenmaxxing
|
|
361
|
-
* check` every 180s against a package that may be gone, silently
|
|
362
|
-
* (closing-review catch): deactivation is checked, not assumed - a job seen
|
|
363
|
-
* loaded must deactivate successfully, and an unanswerable probe (service
|
|
364
|
-
* manager unusable) reports false rather than pretending it is gone. */
|
|
365
312
|
function uninstallCheckTimer(): boolean {
|
|
366
|
-
// Nix owns the timer: do not bootout/disable the declarative unit.
|
|
367
313
|
if (skipImperativeTimer()) return true;
|
|
368
314
|
if (process.platform === "darwin") {
|
|
369
315
|
const domain = launchdDomain();
|
|
@@ -380,8 +326,6 @@ function uninstallCheckTimer(): boolean {
|
|
|
380
326
|
return deactivated;
|
|
381
327
|
}
|
|
382
328
|
|
|
383
|
-
/** The rc file of the user's login shell, or null when the shell is unknown.
|
|
384
|
-
* Overridable for hermetic tests. */
|
|
385
329
|
export function shellRcPath(): string | null {
|
|
386
330
|
const override = process.env.TOKENMAXXING_SHELL_RC;
|
|
387
331
|
if (override && override.length > 0) return override;
|
|
@@ -393,54 +337,45 @@ export function shellRcPath(): string | null {
|
|
|
393
337
|
|
|
394
338
|
const PATH_LINE_MARK = "# tokenmaxxing PATH";
|
|
395
339
|
|
|
396
|
-
|
|
397
|
-
* A pre-existing hand-added line for the bin dir also counts as present. */
|
|
398
|
-
export function ensurePathInRc(rc: string): "added" | "present" {
|
|
340
|
+
export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
399
341
|
const dir = paths.binDir.startsWith(`${HOME}/`) ? `$HOME${paths.binDir.slice(HOME.length)}` : paths.binDir;
|
|
400
|
-
// Write through a dotfile-managed symlink, never over it: writeFileAtomic
|
|
401
|
-
// renames a sibling temp over its target, which would replace the link with
|
|
402
|
-
// a plain file while the dotfiles target keeps the stale line (PR #36
|
|
403
|
-
// second-round catch).
|
|
404
342
|
const target = existsSync(rc) ? realpathSync(rc) : rc;
|
|
405
343
|
const current = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
406
344
|
const isCurrentExport = (line: string) => line.includes(`${paths.binDir}:`) || line.includes(`${dir}:`);
|
|
407
345
|
const lines = current === "" ? [] : current.split("\n");
|
|
408
|
-
// A marked line for a DIFFERENT dir is removed even when the current dir is
|
|
409
|
-
// also exported: PATH prepends stack, so a stale marked line BELOW the
|
|
410
|
-
// current one would still win resolution - the recursion incident's exact
|
|
411
|
-
// vector (closing-review catch + PR #36 second-round catch). A bare marker
|
|
412
|
-
// check alone once kept such a line alive after a TOKENMAXXING_HOME
|
|
413
|
-
// relocation.
|
|
414
346
|
const kept = lines.filter((line) => isCurrentExport(line) || !line.includes(PATH_LINE_MARK));
|
|
415
347
|
if (kept.length !== lines.length) {
|
|
348
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
416
349
|
const body = kept.join("\n");
|
|
417
350
|
const sep0 = body === "" || body.endsWith("\n") ? "" : "\n";
|
|
418
351
|
const addition = kept.some(isCurrentExport) ? "" : `export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
352
|
+
try {
|
|
353
|
+
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (isEacces(e)) return "skipped";
|
|
356
|
+
throw e;
|
|
357
|
+
}
|
|
422
358
|
return "added";
|
|
423
359
|
}
|
|
424
360
|
if (lines.some(isCurrentExport)) return "present";
|
|
361
|
+
if (cannotWriteRcTarget(target)) return "skipped";
|
|
425
362
|
const sep = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
426
|
-
|
|
363
|
+
try {
|
|
364
|
+
appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
|
|
365
|
+
} catch (e) {
|
|
366
|
+
if (isEacces(e)) return "skipped";
|
|
367
|
+
throw e;
|
|
368
|
+
}
|
|
427
369
|
return "added";
|
|
428
370
|
}
|
|
429
371
|
|
|
430
372
|
const ShellShadowerSchema = z.object({
|
|
431
|
-
/** shadow: a `claude` alias/function hides the wrapper entirely.
|
|
432
|
-
* bypass: another alias (e.g. `cc`, `cco`) hardcodes an absolute path to a
|
|
433
|
-
* claude binary, so launches through it skip supervision. */
|
|
434
373
|
kind: z.enum(["shadow", "bypass"]),
|
|
435
374
|
name: z.string(),
|
|
436
375
|
line: z.string(),
|
|
437
376
|
});
|
|
438
377
|
export type ShellShadower = z.infer<typeof ShellShadowerSchema>;
|
|
439
378
|
|
|
440
|
-
/** Scan shell-rc text for aliases/functions that shadow `claude` or hardcode a
|
|
441
|
-
* path to a claude binary. Aliases whose body starts with plain `claude` are
|
|
442
|
-
* fine (they expand through PATH into the wrapper); an absolute path is not.
|
|
443
|
-
* Lines referencing the wrapper itself are deliberate and skipped. */
|
|
444
379
|
export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
445
380
|
const out: ShellShadower[] = [];
|
|
446
381
|
const absClaude = /(?:^|[\s"'=])(\/[^\s"']*\/claude)(?:[\s"']|$)/;
|
|
@@ -463,21 +398,19 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
|
463
398
|
return out;
|
|
464
399
|
}
|
|
465
400
|
|
|
466
|
-
/** Remove ONLY the marker-tagged PATH line this tool added; a hand-added
|
|
467
|
-
* PATH entry without the marker is the user's own. A stale `# tokenmaxxing
|
|
468
|
-
* PATH` line pointing at an emptied binDir is exactly how the supervisor
|
|
469
|
-
* recursion incident started (.memory/supervisor-recursion-guards.md), so
|
|
470
|
-
* uninstall must not leave one behind (closing-review catch).
|
|
471
|
-
* Returns true when a line was removed. */
|
|
472
401
|
export function removePathFromRc(rc: string): boolean {
|
|
473
402
|
if (!existsSync(rc)) return false;
|
|
474
|
-
// same symlink + mode treatment as ensurePathInRc: write through a
|
|
475
|
-
// dotfile-managed link, keep the rc's own permissions (PR #36 catches)
|
|
476
403
|
const target = realpathSync(rc);
|
|
477
404
|
const lines = readFileSync(target, "utf8").split("\n");
|
|
478
405
|
const kept = lines.filter((line) => !line.includes(PATH_LINE_MARK));
|
|
479
406
|
if (kept.length === lines.length) return false;
|
|
480
|
-
|
|
407
|
+
if (cannotWriteRcTarget(target)) return false;
|
|
408
|
+
try {
|
|
409
|
+
writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
|
|
410
|
+
} catch (e) {
|
|
411
|
+
if (isEacces(e)) return false;
|
|
412
|
+
throw e;
|
|
413
|
+
}
|
|
481
414
|
return true;
|
|
482
415
|
}
|
|
483
416
|
|
package/src/lib/keychain.ts
CHANGED
|
@@ -1,42 +1,19 @@
|
|
|
1
|
-
// macOS login-keychain generic-password I/O, ps-safe. The darwin backend of
|
|
2
|
-
// credstore.ts - construct targets there, not here.
|
|
3
|
-
// READ : `security find-generic-password -w` → secret only ever in stdout.
|
|
4
|
-
// WRITE : pipe an `add-generic-password -U ... -w <secret>` line into `security -i`
|
|
5
|
-
// over STDIN - the secret never appears in any process's argv (verified).
|
|
6
|
-
// The service/account are non-secret and may sit in argv.
|
|
7
|
-
|
|
8
1
|
import { z } from "zod";
|
|
9
2
|
|
|
10
3
|
const KeychainTargetSchema = z.object({ service: z.string(), account: z.string() });
|
|
11
4
|
export type KeychainTarget = z.infer<typeof KeychainTargetSchema>;
|
|
12
5
|
|
|
13
6
|
const SECURITY = "/usr/bin/security";
|
|
14
|
-
// security(1) interactive mode has a 4096-byte line buffer. The gate below
|
|
15
|
-
// measures the ASSEMBLED line against this (with margin), never the raw
|
|
16
|
-
// secret: quoteDouble expansion (one byte per `"`/`\` when the blob contains
|
|
17
|
-
// an apostrophe) once pushed a raw-length-passing line over the buffer, which
|
|
18
|
-
// SPLITS the line - the write exits 1 ("unknown command" for the spilled
|
|
19
|
-
// remainder) AND the item is left holding a TRUNCATED secret (empirically
|
|
20
|
-
// verified 2026-07-20: a 3667-byte secret assembling to a 5574-byte line
|
|
21
|
-
// corrupted the item to its first 2682 bytes before the error surfaced).
|
|
22
7
|
const INTERACTIVE_MAX_LINE = 4000;
|
|
23
8
|
|
|
24
|
-
/** Double-quote + backslash-escape for security(1)'s interactive tokenizer. */
|
|
25
9
|
function quoteDouble(s: string): string {
|
|
26
10
|
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
27
11
|
}
|
|
28
12
|
|
|
29
|
-
/** Wrap a value for the `-i` line. Single-quote when possible (blob has no `'`). */
|
|
30
13
|
function quoteValue(s: string): string {
|
|
31
14
|
return s.includes("'") ? quoteDouble(s) : `'${s}'`;
|
|
32
15
|
}
|
|
33
16
|
|
|
34
|
-
/** Read an item's password blob. Returns null ONLY when the item verifiably
|
|
35
|
-
* does not exist (exit 44, errSecItemNotFound - empirically pinned on this
|
|
36
|
-
* Mac 2026-07-20). Every other failure THROWS: a locked keychain or denied
|
|
37
|
-
* ACL prompt reading as "absent" silently disarmed every fail-closed
|
|
38
|
-
* live-owner guard and the mandatory pre-swap harvest, all of which key off
|
|
39
|
-
* a null read (closing-review catch). stderr never carries the secret. */
|
|
40
17
|
export async function readItem(t: KeychainTarget): Promise<string | null> {
|
|
41
18
|
const p = Bun.spawn([SECURITY, "find-generic-password", "-s", t.service, "-a", t.account, "-w"], {
|
|
42
19
|
stdout: "pipe",
|
|
@@ -48,11 +25,9 @@ export async function readItem(t: KeychainTarget): Promise<string | null> {
|
|
|
48
25
|
if (p.exitCode !== 0) {
|
|
49
26
|
throw new Error(`keychain read failed (exit ${p.exitCode}): ${err.trim().slice(0, 200)} - a locked keychain or denied ACL must fail loudly, never read as absent`);
|
|
50
27
|
}
|
|
51
|
-
return out.replace(/\n$/, "");
|
|
28
|
+
return out.replace(/\n$/, "");
|
|
52
29
|
}
|
|
53
30
|
|
|
54
|
-
/** The full `security -i` line for a write; its LENGTH is the argv-fallback
|
|
55
|
-
* gate, so it is assembled once, here. */
|
|
56
31
|
function interactiveLine(t: KeychainTarget, secret: string): string {
|
|
57
32
|
return (
|
|
58
33
|
`add-generic-password -U -a ${quoteDouble(t.account)} -s ${quoteDouble(t.service)} ` +
|
|
@@ -60,8 +35,6 @@ function interactiveLine(t: KeychainTarget, secret: string): string {
|
|
|
60
35
|
);
|
|
61
36
|
}
|
|
62
37
|
|
|
63
|
-
/** ps-safe write: the command line + secret arrive on stdin, never in argv.
|
|
64
|
-
* The caller guarantees the encoded line fits the interactive buffer. */
|
|
65
38
|
async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
|
|
66
39
|
const p = Bun.spawn([SECURITY, "-i"], {
|
|
67
40
|
stdin: encodedLine,
|
|
@@ -73,9 +46,6 @@ async function writeViaInteractive(encodedLine: Uint8Array): Promise<void> {
|
|
|
73
46
|
if (p.exitCode !== 0) throw new Error(`keychain write (interactive) failed (exit ${p.exitCode}): ${err.trim()}`);
|
|
74
47
|
}
|
|
75
48
|
|
|
76
|
-
/** Fallback for blobs over the interactive line limit. The secret is briefly
|
|
77
|
-
* visible in `ps` for this one short-lived process - used only when the payload
|
|
78
|
-
* (e.g. a live blob with lots of MCP OAuth state) exceeds ~4KB. */
|
|
79
49
|
async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
|
|
80
50
|
const p = Bun.spawn([SECURITY, "add-generic-password", "-U", "-a", t.account, "-s", t.service, "-w", secret], {
|
|
81
51
|
stdout: "ignore",
|
|
@@ -86,20 +56,12 @@ async function writeViaArgv(t: KeychainTarget, secret: string): Promise<void> {
|
|
|
86
56
|
if (p.exitCode !== 0) throw new Error(`keychain write (argv) failed (exit ${p.exitCode}): ${err.trim()}`);
|
|
87
57
|
}
|
|
88
58
|
|
|
89
|
-
/** Create-or-update an item (`-U`) with `secret` as its password. Prefers the
|
|
90
|
-
* ps-safe stdin path; falls back to argv when the ASSEMBLED interactive line
|
|
91
|
-
* would exceed the buffer (see INTERACTIVE_MAX_LINE - gating on the raw
|
|
92
|
-
* secret length let quote expansion corrupt the item). Throws on failure. */
|
|
93
59
|
export async function writeItem(t: KeychainTarget, secret: string): Promise<void> {
|
|
94
|
-
// measured in UTF-8 BYTES, the unit the stdin buffer actually consumes:
|
|
95
|
-
// String.length counts UTF-16 code units and under-counts multibyte
|
|
96
|
-
// characters (cubic review catch, PR #35).
|
|
97
60
|
const encoded = new TextEncoder().encode(interactiveLine(t, secret));
|
|
98
61
|
if (encoded.length <= INTERACTIVE_MAX_LINE) return writeViaInteractive(encoded);
|
|
99
62
|
return writeViaArgv(t, secret);
|
|
100
63
|
}
|
|
101
64
|
|
|
102
|
-
/** Delete an item. Returns true if it existed and was removed. */
|
|
103
65
|
export async function deleteItem(t: KeychainTarget): Promise<boolean> {
|
|
104
66
|
const p = Bun.spawn([SECURITY, "delete-generic-password", "-s", t.service, "-a", t.account], {
|
|
105
67
|
stdout: "ignore",
|
package/src/lib/lock.ts
CHANGED
|
@@ -1,34 +1,17 @@
|
|
|
1
|
-
// Cross-process advisory locking via flock(2) through bun:ffi on a real file
|
|
2
|
-
// descriptor (macOS ships no flock(1) binary, and one codepath serves both
|
|
3
|
-
// platforms). The lock is released when we close the fd (explicitly or on
|
|
4
|
-
// process exit).
|
|
5
|
-
//
|
|
6
|
-
// The acquire is NON-BLOCKING (LOCK_EX|LOCK_NB) with an async retry loop: a
|
|
7
|
-
// blocking LOCK_EX from this runtime freezes the whole event loop.
|
|
8
|
-
// EWOULDBLOCK is told apart from real failures via errno, so a bad fd still
|
|
9
|
-
// fails fast instead of spinning.
|
|
10
|
-
|
|
11
1
|
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
12
2
|
import { dirname } from "node:path";
|
|
13
3
|
import { dlopen, FFIType, read } from "bun:ffi";
|
|
14
4
|
import { delay } from "es-toolkit";
|
|
15
5
|
|
|
16
|
-
// sys/file.h (identical on darwin + linux): LOCK_SH=1 LOCK_EX=2 LOCK_NB=4 LOCK_UN=8
|
|
17
6
|
const LOCK_EX = 2;
|
|
18
7
|
const LOCK_NB = 4;
|
|
19
8
|
const LOCK_UN = 8;
|
|
20
|
-
// errno.h: EWOULDBLOCK/EAGAIN is 35 on darwin, 11 on linux; EINTR is 4 on both.
|
|
21
9
|
const EAGAIN = process.platform === "darwin" ? 35 : 11;
|
|
22
10
|
const EINTR = 4;
|
|
23
11
|
const RETRY_MS = 75;
|
|
24
12
|
|
|
25
13
|
const FLOCK_DEF = { flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 } } as const;
|
|
26
14
|
|
|
27
|
-
// darwin: flock(2) + __error live in libSystem; the bare name resolves via the
|
|
28
|
-
// dyld shared cache even though no physical .dylib exists on disk (verified
|
|
29
|
-
// 2026-07-08). linux: glibc's libc.so.6 + __errno_location (verified
|
|
30
|
-
// in-container 2026-07-09, arm64). Exactly one verified path per platform -
|
|
31
|
-
// a broken environment fails fast instead of falling through a guess list.
|
|
32
15
|
function loadLibc() {
|
|
33
16
|
if (process.platform === "darwin") {
|
|
34
17
|
const lib = dlopen("libSystem.B.dylib", { ...FLOCK_DEF, __error: { args: [], returns: FFIType.ptr } });
|
|
@@ -50,12 +33,6 @@ function currentErrno(): number {
|
|
|
50
33
|
return p == null ? -1 : read.i32(p, 0);
|
|
51
34
|
}
|
|
52
35
|
|
|
53
|
-
/**
|
|
54
|
-
* Acquire an exclusive advisory lock on `lockPath`, waiting (without blocking
|
|
55
|
-
* the event loop) until available. Returns a handle whose release() drops the
|
|
56
|
-
* lock. The lock is fd-scoped, so racing hooks in separate processes serialize
|
|
57
|
-
* and same-process actors queue on the retry loop.
|
|
58
|
-
*/
|
|
59
36
|
export async function acquireLock(lockPath: string): Promise<{ release: () => void }> {
|
|
60
37
|
mkdirSync(dirname(lockPath), { recursive: true });
|
|
61
38
|
const fd = openSync(lockPath, "a", 0o600);
|
|
@@ -85,7 +62,6 @@ export async function acquireLock(lockPath: string): Promise<{ release: () => vo
|
|
|
85
62
|
return { release };
|
|
86
63
|
}
|
|
87
64
|
|
|
88
|
-
/** Run `fn` while holding `lockPath`; always releases, even on throw. */
|
|
89
65
|
export async function withLock<T>(lockPath: string, fn: () => Promise<T> | T): Promise<T> {
|
|
90
66
|
const held = await acquireLock(lockPath);
|
|
91
67
|
try {
|
package/src/lib/log.ts
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// Append-only logging. NEVER logs secret material - callers must pass only
|
|
2
|
-
// non-secret context (account uuids/emails, percentages, status strings).
|
|
3
|
-
|
|
4
1
|
import { appendFileSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
|
|
5
2
|
import { dirname } from "node:path";
|
|
6
3
|
import { z } from "zod";
|
|
@@ -8,19 +5,14 @@ import { paths } from "./paths.ts";
|
|
|
8
5
|
|
|
9
6
|
const LOG_MAX_BYTES = 5_000_000;
|
|
10
7
|
|
|
11
|
-
/** Redact anything that looks like a token so an accidental pass-through can't leak. */
|
|
12
8
|
function redact(s: string): string {
|
|
13
9
|
return s
|
|
14
|
-
// JWT-ish / long opaque tokens
|
|
15
10
|
.replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
|
|
16
11
|
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}...(${m.length})`);
|
|
17
12
|
}
|
|
18
13
|
|
|
19
14
|
let echo: ((input: { event: string; parts: string }) => void) | null = null;
|
|
20
15
|
|
|
21
|
-
/** Tee every subsequent log() line to a terminal printer. Off by default
|
|
22
|
-
* (hooks and the statusline own their stdout protocol). The printer receives
|
|
23
|
-
* the same redacted parts the file line gets. */
|
|
24
16
|
export function setLogEcho(input: { printer: (input: { event: string; parts: string }) => void }): void {
|
|
25
17
|
echo = input.printer;
|
|
26
18
|
}
|
|
@@ -35,20 +27,14 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
|
|
|
35
27
|
})
|
|
36
28
|
.join(" ");
|
|
37
29
|
mkdirSync(dirname(paths.logFile), { recursive: true });
|
|
38
|
-
// Rotation cap: the check timer logs every 180s, so an uncapped
|
|
39
|
-
// append-only file grows forever on a live install. One .old generation
|
|
40
|
-
// bounds total disk at ~2x the cap; older history is disposable diagnostics.
|
|
41
30
|
if (existsSync(paths.logFile) && statSync(paths.logFile).size > LOG_MAX_BYTES) {
|
|
42
31
|
renameSync(paths.logFile, `${paths.logFile}.old`);
|
|
43
32
|
}
|
|
44
33
|
appendFileSync(paths.logFile, `${new Date().toISOString()} ${event} ${line}\n`);
|
|
45
34
|
} catch {
|
|
46
|
-
// logging must never throw into a hook / supervisor path
|
|
47
35
|
}
|
|
48
|
-
// separate from the file sink: an unwritable log file must not also silence the echo.
|
|
49
36
|
try {
|
|
50
37
|
echo?.({ event, parts: line });
|
|
51
38
|
} catch {
|
|
52
|
-
// the echo printer must never throw into a caller path
|
|
53
39
|
}
|
|
54
40
|
}
|