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.
Files changed (67) hide show
  1. package/DESIGN.md +2 -2
  2. package/README.md +1 -1
  3. package/agent-plugin/plugin.json +8 -2
  4. package/package.json +1 -1
  5. package/src/cli/add.ts +1 -8
  6. package/src/cli/auth.ts +0 -23
  7. package/src/cli/check.ts +19 -13
  8. package/src/cli/codexadd.ts +0 -17
  9. package/src/cli/codexinit.ts +11 -42
  10. package/src/cli/codexrm.ts +0 -13
  11. package/src/cli/codexswitch.ts +0 -15
  12. package/src/cli/config.ts +0 -30
  13. package/src/cli/doctor.ts +1 -14
  14. package/src/cli/init.ts +8 -34
  15. package/src/cli/ls.ts +0 -2
  16. package/src/cli/onboard.ts +0 -37
  17. package/src/cli/rename.ts +0 -19
  18. package/src/cli/render.ts +0 -23
  19. package/src/cli/rm.ts +0 -19
  20. package/src/cli/status.ts +0 -80
  21. package/src/cli/switch.ts +1 -49
  22. package/src/cli/watch.ts +0 -17
  23. package/src/entries/codexstophook.ts +2 -63
  24. package/src/entries/codexsupervisor.ts +1 -67
  25. package/src/entries/mcp.ts +0 -11
  26. package/src/entries/sessionstart.ts +1 -8
  27. package/src/entries/statusline.ts +0 -66
  28. package/src/entries/stopfailurehook.ts +93 -0
  29. package/src/entries/stophook.ts +3 -23
  30. package/src/entries/subagentstatusline.ts +0 -19
  31. package/src/entries/supervisor.ts +32 -132
  32. package/src/lib/atomic.ts +0 -16
  33. package/src/lib/claudebin.ts +4 -55
  34. package/src/lib/claudejson.ts +0 -10
  35. package/src/lib/claudelock.ts +13 -35
  36. package/src/lib/codexauth.ts +0 -29
  37. package/src/lib/codexbin.ts +0 -10
  38. package/src/lib/codexdecide.ts +1 -112
  39. package/src/lib/codexoauth.ts +0 -16
  40. package/src/lib/codexpick.ts +0 -31
  41. package/src/lib/codexpresence.ts +0 -35
  42. package/src/lib/codexsample.ts +0 -23
  43. package/src/lib/codexstate.ts +0 -7
  44. package/src/lib/codexswap.ts +0 -32
  45. package/src/lib/codexusage.ts +0 -28
  46. package/src/lib/credstore.ts +0 -24
  47. package/src/lib/decide.ts +127 -180
  48. package/src/lib/http.ts +0 -9
  49. package/src/lib/install.ts +57 -124
  50. package/src/lib/keychain.ts +1 -39
  51. package/src/lib/lock.ts +0 -24
  52. package/src/lib/log.ts +0 -14
  53. package/src/lib/oauth.ts +1 -31
  54. package/src/lib/paths.ts +1 -45
  55. package/src/lib/picker.ts +1 -84
  56. package/src/lib/proc.ts +0 -17
  57. package/src/lib/sample.ts +0 -68
  58. package/src/lib/sessions.ts +0 -13
  59. package/src/lib/settings.ts +15 -42
  60. package/src/lib/state.ts +23 -77
  61. package/src/lib/swap.ts +3 -87
  62. package/src/lib/tty.ts +0 -4
  63. package/src/lib/types.ts +13 -140
  64. package/src/lib/usage.ts +108 -196
  65. package/src/lib/worktree.ts +0 -8
  66. package/src/main.ts +5 -35
  67. package/src/sdk.ts +0 -59
@@ -1,8 +1,4 @@
1
- // Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
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
- /** Nix supervisor shim: prefer a PATH-stable `tokenmaxxing` (profile /
58
- * current-system, excluding this binDir) so upgrades/GC of an old store
59
- * generation stay reachable; fall back to bun+entry for the rare
60
- * `nix run ... -- init` case where nothing is on PATH yet (works until that
61
- * generation is GC'd — docs steer users to `nix profile install` first). */
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(); // binDir/tokenmaxxing
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
- // ---- periodic `check` timer ------------------------------------------------
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}`]); // reload a changed plist
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
- // systemd: quote the path and escape `%` (unit-file specifier character).
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=30
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
- /** Idempotently append the supervisor-bin PATH line to `rc` (created if absent).
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
- // preserve the rc's own mode: writeFileAtomic defaults to 0600, which
420
- // would silently tighten a normally 0644 shell rc (PR #36 review catch)
421
- writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
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
- appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
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
- writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
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
 
@@ -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$/, ""); // security appends exactly one trailing newline
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
  }