agent-yes 1.258.3 → 1.259.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 (57) hide show
  1. package/dist/{SUPPORTED_CLIS-DD6bhkyI.js → SUPPORTED_CLIS-BBeIFAZD.js} +2 -2
  2. package/dist/SUPPORTED_CLIS-BRgPb6Pz.js +11 -0
  3. package/dist/{agentShare-CoI7IHeq.js → agentShare-90Y_SFqK.js} +4 -4
  4. package/dist/{callback-CbdkyTRw.js → callback-CLzRxjEn.js} +3 -3
  5. package/dist/{callback-DNI__ASS.js → callback-DYQ2jQi2.js} +4 -4
  6. package/dist/cli.js +11 -11
  7. package/dist/{cwdConflictWarn-DZTxTGXq.js → cwdConflictWarn-B_047KCF.js} +2 -2
  8. package/dist/{forkNested-CgJ1rTnT.js → forkNested-CBdhC852.js} +2 -2
  9. package/dist/globalPidIndex-CHA__9Xf.js +4 -0
  10. package/dist/{globalPidIndex-Dq4KY2IV.js → globalPidIndex-pwp7Vb7o.js} +8 -2
  11. package/dist/index.js +4 -4
  12. package/dist/{notifyDaemon-BQ6_UbJS.js → notifyDaemon-D5RtFwMq.js} +4 -4
  13. package/dist/pidStore-1PQ4ARnh.js +6 -0
  14. package/dist/{pidStore-Cmcc8Oej.js → pidStore-DfSm1jog.js} +7 -4
  15. package/dist/{remotes-Dy4Rg8Yp.js → remotes-Cn-wszbK.js} +2 -2
  16. package/dist/{remotes-CNe85Icx.js → remotes-lOhHSoSS.js} +1 -1
  17. package/dist/{rustBinary-orhBmTNo.js → rustBinary-BEscpLLP.js} +2 -2
  18. package/dist/{schedule-BQy6gXYk.js → schedule-Cw5nW_di.js} +6 -6
  19. package/dist/{serve-C2mQMjUR.js → serve-DctJ7tY-.js} +19 -19
  20. package/dist/{setup-EEF1vfhi.js → setup-BA9D1ExO.js} +10 -2
  21. package/dist/{spawnGate-D0E1uRP7.js → spawnGate-D-68B907.js} +2 -2
  22. package/dist/spawnGate-DbSHnfqA.js +5 -0
  23. package/dist/{subcommands-CiX7J7hA.js → subcommands-D_cPHm34.js} +93 -24
  24. package/dist/{subcommands-S7uULM_d.js → subcommands-Dg7KkEi6.js} +3 -3
  25. package/dist/systemPathLink-DTAhFXFx.js +91 -0
  26. package/dist/{terminal-i7IimD_R.js → terminal-CB5K4Jjh.js} +2 -2
  27. package/dist/{todoCli-B2kqgqPh.js → todoCli-DNOk38lq.js} +2 -2
  28. package/dist/{tray-CgkwxtG6.js → tray-wWMvGWeY.js} +1 -1
  29. package/dist/trayApp-B1OhYfj3.js +5 -0
  30. package/dist/{trayApp-4sZgxxP-.js → trayApp-Ck3kWM-7.js} +2 -2
  31. package/dist/{ts-D-E4xqpA.js → ts-Bd_7sgVu.js} +5 -4
  32. package/dist/{versionChecker-YDl8a9il.js → versionChecker-DRijpksb.js} +2 -2
  33. package/dist/{webrtcRemote-C3OlHYSt.js → webrtcRemote-BkZcA5yq.js} +1 -1
  34. package/dist/{widget-7j9RrYZR.js → widget-DiRKbtng.js} +3 -3
  35. package/dist/{ws-DjyQ20AD.js → ws-Bp8eOEoV.js} +2 -2
  36. package/dist/{ws-Clem1WQ-.js → ws-Cqn8mxsS.js} +4 -4
  37. package/package.json +1 -1
  38. package/ts/globalPidIndex.spec.ts +36 -0
  39. package/ts/globalPidIndex.ts +19 -1
  40. package/ts/hist.spec.ts +219 -0
  41. package/ts/hist.ts +136 -0
  42. package/ts/histStore.spec.ts +351 -0
  43. package/ts/histStore.ts +494 -0
  44. package/ts/index.ts +4 -0
  45. package/ts/messageLog.ts +2 -0
  46. package/ts/pidStore.spec.ts +26 -0
  47. package/ts/pidStore.ts +10 -0
  48. package/ts/setup.ts +17 -0
  49. package/ts/subcommands.spec.ts +180 -0
  50. package/ts/subcommands.ts +103 -3
  51. package/ts/systemPathLink.spec.ts +69 -0
  52. package/ts/systemPathLink.ts +134 -0
  53. package/dist/SUPPORTED_CLIS-D1BoydrZ.js +0 -11
  54. package/dist/globalPidIndex-CQX_0JSB.js +0 -4
  55. package/dist/pidStore-BbKqQOS8.js +0 -6
  56. package/dist/spawnGate-DqKBA07k.js +0 -5
  57. package/dist/trayApp-C35IkXnz.js +0 -5
@@ -2675,6 +2675,119 @@ describe("subcommands.cmdWhoami", () => {
2675
2675
  });
2676
2676
  });
2677
2677
 
2678
+ describe("subcommands.cmdRole", () => {
2679
+ async function registerAgent(over: Record<string, unknown> = {}) {
2680
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
2681
+ await appendGlobalPid({
2682
+ pid: 910001,
2683
+ cli: "claude",
2684
+ prompt: null,
2685
+ cwd: "/repo/lane",
2686
+ log_file: null,
2687
+ status: "active",
2688
+ exit_code: null,
2689
+ exit_reason: null,
2690
+ started_at: Date.now(),
2691
+ wrapper_pid: 515151,
2692
+ agent_id: "eeee0000ffff",
2693
+ ...over,
2694
+ } as any);
2695
+ }
2696
+
2697
+ function capture() {
2698
+ const out: string[] = [];
2699
+ const err: string[] = [];
2700
+ const origOut = process.stdout.write.bind(process.stdout);
2701
+ const origErr = process.stderr.write.bind(process.stderr);
2702
+ (process.stdout as any).write = (s: any) => (out.push(String(s)), true);
2703
+ (process.stderr as any).write = (s: any) => (err.push(String(s)), true);
2704
+ return {
2705
+ out,
2706
+ err,
2707
+ restore() {
2708
+ process.stdout.write = origOut;
2709
+ process.stderr.write = origErr;
2710
+ },
2711
+ };
2712
+ }
2713
+
2714
+ async function withAgentEnv<T>(fn: () => Promise<T>): Promise<T> {
2715
+ const saved = process.env.AGENT_YES_PID;
2716
+ process.env.AGENT_YES_PID = "515151";
2717
+ try {
2718
+ return await fn();
2719
+ } finally {
2720
+ if (saved === undefined) delete process.env.AGENT_YES_PID;
2721
+ else process.env.AGENT_YES_PID = saved;
2722
+ }
2723
+ }
2724
+
2725
+ it("self get/set/clear round-trips via agent context", async () => {
2726
+ const { runSubcommand } = await loadModule();
2727
+ await registerAgent();
2728
+ await withAgentEnv(async () => {
2729
+ let cap = capture();
2730
+ try {
2731
+ expect(await runSubcommand(["bun", "cli.js", "role", "pm"])).toBe(0);
2732
+ expect(cap.out.join("")).toContain('role set to "pm"');
2733
+ } finally {
2734
+ cap.restore();
2735
+ }
2736
+ cap = capture();
2737
+ try {
2738
+ expect(await runSubcommand(["bun", "cli.js", "role"])).toBe(0);
2739
+ expect(cap.out.join("")).toBe("pm\n");
2740
+ expect(await runSubcommand(["bun", "cli.js", "role", "--clear"])).toBe(0);
2741
+ expect(await runSubcommand(["bun", "cli.js", "role"])).toBe(0);
2742
+ expect(cap.out.join("")).toContain("role cleared");
2743
+ } finally {
2744
+ cap.restore();
2745
+ }
2746
+ });
2747
+ });
2748
+
2749
+ it("keyword set and keyword clear work from a human shell", async () => {
2750
+ const { runSubcommand } = await loadModule();
2751
+ await registerAgent();
2752
+ let cap = capture();
2753
+ try {
2754
+ expect(await runSubcommand(["bun", "cli.js", "role", "910001", "crm"])).toBe(0);
2755
+ expect(cap.out.join("")).toContain('role set to "crm"');
2756
+ expect(await runSubcommand(["bun", "cli.js", "role", "910001", "--clear"])).toBe(0);
2757
+ expect(cap.out.join("")).toContain("role cleared");
2758
+ } finally {
2759
+ cap.restore();
2760
+ }
2761
+ });
2762
+
2763
+ it("rejects header-forging role values", async () => {
2764
+ const { runSubcommand } = await loadModule();
2765
+ await registerAgent();
2766
+ const cap = capture();
2767
+ try {
2768
+ expect(await runSubcommand(["bun", "cli.js", "role", "910001", "a b>"])).toBe(1);
2769
+ expect(cap.err.join("")).toContain("invalid role");
2770
+ } finally {
2771
+ cap.restore();
2772
+ }
2773
+ });
2774
+
2775
+ it("errors without agent context when no keyword is given", async () => {
2776
+ const { runSubcommand } = await loadModule();
2777
+ const saved = process.env.AGENT_YES_PID;
2778
+ delete process.env.AGENT_YES_PID;
2779
+ const cap = capture();
2780
+ try {
2781
+ expect(await runSubcommand(["bun", "cli.js", "role"])).toBe(1);
2782
+ expect(await runSubcommand(["bun", "cli.js", "role", "somename"])).toBe(1);
2783
+ expect(cap.err.join("")).toContain("no agent context");
2784
+ } finally {
2785
+ cap.restore();
2786
+ if (saved !== undefined) process.env.AGENT_YES_PID = saved;
2787
+ }
2788
+ });
2789
+ });
2790
+
2678
2791
  describe("subcommands.cmdSend double-envelope warning", () => {
2679
2792
  const itUnix = process.platform === "linux" || process.platform === "darwin";
2680
2793
 
@@ -2819,4 +2932,71 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2819
2932
  await rm(tmp, { recursive: true, force: true }).catch(() => null);
2820
2933
  }
2821
2934
  });
2935
+
2936
+ it.skipIf(!itUnix)("a sender with a role gets it rendered into the envelope header", async () => {
2937
+ const { runSubcommand } = await loadModule();
2938
+ const { appendGlobalPid } = await import("./globalPidIndex.ts");
2939
+ const { spawnSync } = await import("child_process");
2940
+ const tmp = await mkdtemp(path.join(tmpdir(), "ay-fifo-"));
2941
+ try {
2942
+ const fifo = path.join(tmp, "role.fifo");
2943
+ if (spawnSync("mkfifo", [fifo]).status !== 0) return;
2944
+ const fs = await import("fs");
2945
+ const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR);
2946
+ await appendGlobalPid({
2947
+ pid: process.pid,
2948
+ cli: "claude",
2949
+ prompt: null,
2950
+ cwd: "/repo/alpha",
2951
+ log_file: null,
2952
+ fifo_file: fifo,
2953
+ status: "active",
2954
+ exit_code: null,
2955
+ exit_reason: null,
2956
+ started_at: Date.now(),
2957
+ });
2958
+ await appendGlobalPid({
2959
+ pid: 900001,
2960
+ cli: "claude",
2961
+ prompt: null,
2962
+ cwd: "/repo/beta",
2963
+ log_file: null,
2964
+ status: "active",
2965
+ exit_code: null,
2966
+ exit_reason: null,
2967
+ started_at: Date.now(),
2968
+ wrapper_pid: 424242,
2969
+ agent_id: "cccc0000dddd",
2970
+ role: "crm",
2971
+ });
2972
+ const savedAyPid = process.env.AGENT_YES_PID;
2973
+ process.env.AGENT_YES_PID = "424242";
2974
+ const origOut = process.stdout.write.bind(process.stdout);
2975
+ (process.stdout as any).write = () => true;
2976
+ try {
2977
+ const code = await runSubcommand([
2978
+ "bun",
2979
+ "cli.js",
2980
+ "send",
2981
+ String(process.pid),
2982
+ "hello from crm",
2983
+ "--force",
2984
+ ]);
2985
+ expect(code).toBe(0);
2986
+ } finally {
2987
+ process.stdout.write = origOut;
2988
+ if (savedAyPid === undefined) delete process.env.AGENT_YES_PID;
2989
+ else process.env.AGENT_YES_PID = savedAyPid;
2990
+ }
2991
+ const buf = Buffer.alloc(4096);
2992
+ const n = fs.readSync(rdwrFd, buf, 0, buf.length, null);
2993
+ const received = buf.subarray(0, n).toString();
2994
+ expect(received).toMatch(
2995
+ /^<ay-msg [0-9a-f]{8} from claude #900001 \(crm\) @ \/repo\/beta — reply: ay send cccc0000dddd "\.\.\.">/,
2996
+ );
2997
+ fs.closeSync(rdwrFd);
2998
+ } finally {
2999
+ await rm(tmp, { recursive: true, force: true }).catch(() => null);
3000
+ }
3001
+ });
2822
3002
  });
package/ts/subcommands.ts CHANGED
@@ -16,7 +16,12 @@ import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises"
16
16
  import ms from "ms";
17
17
  import { homedir } from "os";
18
18
  import path from "path";
19
- import { type GlobalPidRecord, readGlobalPids, updateGlobalPidStatus } from "./globalPidIndex.ts";
19
+ import {
20
+ type GlobalPidRecord,
21
+ readGlobalPids,
22
+ sanitizeRole,
23
+ updateGlobalPidStatus,
24
+ } from "./globalPidIndex.ts";
20
25
  import { buildAgentForest, flattenForest } from "./agentTree.ts";
21
26
  import { parseTaskCounts, type TaskCounts } from "./todoParse.ts";
22
27
  import { agentYesHome } from "./agentYesHome.ts";
@@ -321,6 +326,7 @@ async function cmdWhoami(rest: string[]): Promise<number> {
321
326
  const ageMin = Math.max(0, Math.round((Date.now() - self.started_at) / 60_000));
322
327
  const lines = [
323
328
  `agent ${self.cli} #${self.pid}${self.agent_id ? ` (agent_id ${self.agent_id})` : ""}`,
329
+ `role ${self.role ?? "- (set with: ay role <name>)"}`,
324
330
  `state ${state}${question ? ` — ${question}` : ""}`,
325
331
  `cwd ${self.cwd}`,
326
332
  `started ${new Date(self.started_at).toISOString()} (${ageMin}m ago)`,
@@ -328,7 +334,7 @@ async function cmdWhoami(rest: string[]): Promise<number> {
328
334
  `log ${self.log_file ?? "-"}`,
329
335
  `fifo ${self.fifo_file ?? "-"}`,
330
336
  `reply ${reply} "..."`,
331
- `envelope <ay-msg from ${self.cli} #${self.pid} @ ${self.cwd} — reply: ${reply} "...">…</ay-msg>`,
337
+ `envelope <ay-msg from ${self.cli} #${self.pid}${self.role ? ` (${self.role})` : ""} @ ${self.cwd} — reply: ${reply} "...">…</ay-msg>`,
332
338
  ];
333
339
  process.stdout.write(lines.join("\n") + "\n");
334
340
  return 0;
@@ -379,6 +385,7 @@ async function readLocalTsPids(cwd: string): Promise<GlobalPidRecord[]> {
379
385
  exit_code: d.exitCode ?? null,
380
386
  exit_reason: d.exitReason ?? null,
381
387
  started_at: d.startedAt ?? 0,
388
+ role: d.role ?? null,
382
389
  }));
383
390
  }
384
391
 
@@ -413,6 +420,7 @@ const SUBCOMMANDS = new Set([
413
420
  "head",
414
421
  "send",
415
422
  "msgs",
423
+ "role",
416
424
  "key",
417
425
  "select",
418
426
  "spawn",
@@ -545,6 +553,8 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
545
553
  return await cmdSend(rest);
546
554
  case "msgs":
547
555
  return await cmdMsgs(rest);
556
+ case "role":
557
+ return await cmdRole(rest);
548
558
  case "key":
549
559
  return await cmdKey(rest);
550
560
  case "select":
@@ -721,6 +731,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
721
731
  ` ay head <keyword> first N lines\n` +
722
732
  ` ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n` +
723
733
  ` ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n` +
734
+ ` ay role [name] | ay role <kw> <name> lane/role name shown in the send envelope\n` +
724
735
  ` ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n` +
725
736
  ` ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n` +
726
737
  ` ay widget ls | read selection|dom read an opted-in page widget's context (selection/DOM) (ay widget help)\n` +
@@ -3310,7 +3321,13 @@ async function cmdSend(rest: string[]): Promise<number> {
3310
3321
  let nonce: string | undefined;
3311
3322
  if (sender.agent && !isSlashCommand(body) && !raw) {
3312
3323
  nonce = randomBytes(4).toString("hex");
3313
- prefix = `<ay-msg ${nonce} from ${sender.agent.cli} #${sender.agent.pid} @ ${shortenPath(sender.agent.cwd)} — reply: ay send ${replyTarget} "...">\n`;
3324
+ // The sender's self-declared role ("pm", "crm", …) rides along when set —
3325
+ // recipients read dozens of these a day and a pid/cwd doesn't carry the
3326
+ // lane name (cwd → role isn't always derivable). sanitizeRole guarantees
3327
+ // the value can't forge header syntax.
3328
+ const senderRole = sanitizeRole(sender.agent.role);
3329
+ const roleTag = senderRole ? ` (${senderRole})` : "";
3330
+ prefix = `<ay-msg ${nonce} from ${sender.agent.cli} #${sender.agent.pid}${roleTag} @ ${shortenPath(sender.agent.cwd)} — reply: ay send ${replyTarget} "...">\n`;
3314
3331
  suffix = `\n</ay-msg ${nonce}>`;
3315
3332
  // A body that itself starts with an <ay-msg …> header is usually an agent
3316
3333
  // hand-wrapping what this send is about to wrap again — the recipient then
@@ -3391,6 +3408,7 @@ async function cmdSend(rest: string[]): Promise<number> {
3391
3408
  cli: sender.agent.cli,
3392
3409
  cwd: sender.agent.cwd,
3393
3410
  agent_id: sender.agent.agent_id,
3411
+ role: sender.agent.role ?? undefined,
3394
3412
  }
3395
3413
  : null,
3396
3414
  to: {
@@ -3398,6 +3416,7 @@ async function cmdSend(rest: string[]): Promise<number> {
3398
3416
  cli: record.cli,
3399
3417
  cwd: record.cwd,
3400
3418
  agent_id: record.agent_id,
3419
+ role: record.role ?? undefined,
3401
3420
  },
3402
3421
  body,
3403
3422
  code: trailing === "\r" ? undefined : codeName,
@@ -3569,6 +3588,87 @@ async function cmdMsgs(rest: string[]): Promise<number> {
3569
3588
  return 0;
3570
3589
  }
3571
3590
 
3591
+ /**
3592
+ * `ay role [name]` / `ay role <keyword> <name>` — read or set an agent's
3593
+ * self-declared lane/role name ("pm", "crm", …). The role rides in the
3594
+ * `ay send` envelope header (`from claude #123 (pm) @ …`) so recipients don't
3595
+ * translate cwd/pid into a lane by hand — the emergent `[pm→qa]` body prefixes
3596
+ * exist exactly because the envelope lacked this. With no args: print the
3597
+ * calling agent's role. With one arg: set the calling agent's role (requires
3598
+ * agent context). With two args: resolve the keyword and set that agent's role.
3599
+ * `--clear` removes it. Roles are clamped by sanitizeRole (they render into the
3600
+ * non-nonce-protected open tag).
3601
+ */
3602
+ const ROLE_RESOLVE_OPTS: CommonOpts = {
3603
+ all: false,
3604
+ active: false,
3605
+ cwdScope: null,
3606
+ latest: false,
3607
+ json: false,
3608
+ };
3609
+
3610
+ async function cmdRole(rest: string[]): Promise<number> {
3611
+ const y = yargs(rest)
3612
+ .usage('Usage: ay role [name] | ay role <keyword> <name> | ay role [keyword] --clear')
3613
+ .option("clear", { type: "boolean", default: false, description: "Remove the role" })
3614
+ .help(false)
3615
+ .version(false)
3616
+ .exitProcess(false);
3617
+ const argv = await y.parseAsync();
3618
+ const args = argv._.slice(0).map(String);
3619
+
3620
+ // Figure out target + new value from arity: 0 args = self get (or self clear),
3621
+ // 1 arg = self set (or keyword clear), 2 args = keyword set.
3622
+ let target: GlobalPidRecord | null = null;
3623
+ let newRole: string | undefined;
3624
+ if (args.length === 2) {
3625
+ target = await resolveOne(args[0]!, ROLE_RESOLVE_OPTS);
3626
+ newRole = args[1]!;
3627
+ } else if (args.length === 1 && argv.clear) {
3628
+ target = await resolveOne(args[0]!, ROLE_RESOLVE_OPTS);
3629
+ } else if (args.length === 1) {
3630
+ target = await resolveSender();
3631
+ if (!target) {
3632
+ process.stderr.write(
3633
+ "ay role: no agent context (AGENT_YES_PID unset or unregistered) — from a human shell, name the agent: ay role <keyword> <name>\n",
3634
+ );
3635
+ return 1;
3636
+ }
3637
+ newRole = args[0]!;
3638
+ } else {
3639
+ target = await resolveSender();
3640
+ if (!target) {
3641
+ process.stderr.write(
3642
+ "ay role: no agent context — from a human shell, name the agent: ay role <keyword> [<name>|--clear]\n",
3643
+ );
3644
+ return 1;
3645
+ }
3646
+ }
3647
+
3648
+ if (newRole === undefined && !argv.clear) {
3649
+ process.stdout.write(`${target.role ?? ""}\n`);
3650
+ return 0;
3651
+ }
3652
+
3653
+ let role: string | null = null;
3654
+ if (!argv.clear) {
3655
+ role = sanitizeRole(newRole);
3656
+ if (!role) {
3657
+ process.stderr.write(
3658
+ `ay role: invalid role ${JSON.stringify(newRole)} — use 1-32 chars of [A-Za-z0-9._-]\n`,
3659
+ );
3660
+ return 1;
3661
+ }
3662
+ }
3663
+ await updateGlobalPidStatus(target.pid, { role });
3664
+ process.stdout.write(
3665
+ role
3666
+ ? `pid ${target.pid} (${target.cli}): role set to "${role}" — envelopes now read: from ${target.cli} #${target.pid} (${role}) @ ${shortenPath(target.cwd)}\n`
3667
+ : `pid ${target.pid} (${target.cli}): role cleared\n`,
3668
+ );
3669
+ return 0;
3670
+ }
3671
+
3572
3672
  // Resolve a keyword to one agent and return it with a writable FIFO, or throw
3573
3673
  // with the same guidance cmdSend gives. Shared by `ay key` / `ay select`.
3574
3674
  async function resolveWritableAgent(keyword: string, opts: CommonOpts): Promise<GlobalPidRecord> {
@@ -0,0 +1,69 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, lstatSync, readlinkSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { linkBunBinsToSystemPath } from "./systemPathLink.ts";
6
+
7
+ // linkBunBinsToSystemPath is POSIX-only by design (it returns null on win32 —
8
+ // Windows uses a different PATH model), so the behavioral tests only run where
9
+ // the code path exists.
10
+ const itPosix = it.skipIf(process.platform === "win32");
11
+
12
+ describe("systemPathLink.linkBunBinsToSystemPath", () => {
13
+ let root: string;
14
+ let bunDir: string;
15
+ let target: string;
16
+
17
+ beforeEach(() => {
18
+ root = mkdtempSync(path.join(tmpdir(), "ay-pathlink-"));
19
+ bunDir = path.join(root, "bun", "bin");
20
+ target = path.join(root, "usr-local-bin");
21
+ mkdirSync(bunDir, { recursive: true });
22
+ for (const name of ["ay", "cy", "qq"]) writeFileSync(path.join(bunDir, name), "#!/bin/sh\n");
23
+ });
24
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
25
+
26
+ itPosix("symlinks every bun bin into the target dir", () => {
27
+ const r = linkBunBinsToSystemPath({ bunDir, target })!;
28
+ expect(r.linked.sort()).toEqual(["ay", "cy", "qq"]);
29
+ for (const name of ["ay", "cy", "qq"]) {
30
+ const dest = path.join(target, name);
31
+ expect(lstatSync(dest).isSymbolicLink()).toBe(true);
32
+ expect(path.resolve(target, readlinkSync(dest))).toBe(path.join(bunDir, name));
33
+ }
34
+ });
35
+
36
+ itPosix("never clobbers a foreign (real) binary already in the target", () => {
37
+ mkdirSync(target, { recursive: true });
38
+ writeFileSync(path.join(target, "cy"), "#!/bin/sh\n# a different real cy\n");
39
+ const r = linkBunBinsToSystemPath({ bunDir, target })!;
40
+ expect(r.linked.sort()).toEqual(["ay", "qq"]); // cy left alone
41
+ expect(r.skipped).toContain("cy");
42
+ expect(lstatSync(path.join(target, "cy")).isSymbolicLink()).toBe(false); // still the real file
43
+ });
44
+
45
+ itPosix("is idempotent — re-running skips links it already owns", () => {
46
+ linkBunBinsToSystemPath({ bunDir, target });
47
+ const r2 = linkBunBinsToSystemPath({ bunDir, target })!;
48
+ expect(r2.linked).toEqual([]);
49
+ expect(r2.skipped.sort()).toEqual(["ay", "cy", "qq"]);
50
+ });
51
+
52
+ itPosix("refreshes a dangling symlink it owns (name points into bunDir but target gone)", () => {
53
+ mkdirSync(target, { recursive: true });
54
+ // a stale link named "ay" pointing at a now-missing path INSIDE bunDir
55
+ symlinkSync(path.join(bunDir, "ay-old-removed"), path.join(target, "ay"));
56
+ const r = linkBunBinsToSystemPath({ bunDir, target })!;
57
+ expect(r.refreshed).toContain("ay");
58
+ expect(path.resolve(target, readlinkSync(path.join(target, "ay")))).toBe(path.join(bunDir, "ay"));
59
+ });
60
+
61
+ it("returns null when the bun dir is absent", () => {
62
+ expect(linkBunBinsToSystemPath({ bunDir: path.join(root, "nope"), target })).toBeNull();
63
+ });
64
+
65
+ it("returns null on win32 regardless of inputs", () => {
66
+ if (process.platform !== "win32") return; // covered implicitly elsewhere
67
+ expect(linkBunBinsToSystemPath({ bunDir, target })).toBeNull();
68
+ });
69
+ });
@@ -0,0 +1,134 @@
1
+ // Make bun-global CLIs (ay, cy, the *-yes family, qq, rech, …) resolvable from
2
+ // EVERY shell context, including the hard case: non-interactive non-login shells
3
+ // and Claude Code's Bash tool.
4
+ //
5
+ // Why this is needed: Claude Code's Bash tool sources a per-session shell
6
+ // *snapshot* that statically `export`s a PATH captured at session start. If that
7
+ // PATH didn't include `~/.bun/bin` (e.g. the session was spawned from a context
8
+ // whose PATH lacked it), every bun-global CLI is "command not found" — even
9
+ // though an interactive login shell would have them. There is no bash file that
10
+ // reliably augments PATH for non-interactive non-login shells.
11
+ //
12
+ // The robust fix: symlink the bun-global bins into `/usr/local/bin`, which is on
13
+ // PATH in EVERY context — the bare non-login default
14
+ // (`/usr/local/bin:/usr/local/sbin:/usr/bin:…`), every snapshot, and login
15
+ // shells. Non-clobbering (never overwrites an existing entry) and best-effort
16
+ // (a scheduling/permission hiccup must never break setup). POSIX only — Windows
17
+ // uses a different PATH model and isn't the reported failure surface.
18
+
19
+ import {
20
+ existsSync,
21
+ lstatSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ readlinkSync,
25
+ symlinkSync,
26
+ unlinkSync,
27
+ } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import path from "node:path";
30
+
31
+ /** Did a (now-dangling) symlink at `linkPath` point into `dir`? Used to adopt
32
+ * only stale links we previously created, never a foreign broken link. */
33
+ function danglingPointsInto(linkPath: string, dir: string): boolean {
34
+ try {
35
+ const oldTarget = path.resolve(path.dirname(linkPath), readlinkSync(linkPath));
36
+ return path.resolve(path.dirname(oldTarget)) === path.resolve(dir);
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /** The directory bun installs global binaries into, or null if absent. */
43
+ export function bunBinDir(env: NodeJS.ProcessEnv = process.env): string | null {
44
+ const install = env.BUN_INSTALL || path.join(homedir(), ".bun");
45
+ const dir = path.join(install, "bin");
46
+ return existsSync(dir) ? dir : null;
47
+ }
48
+
49
+ export interface LinkResult {
50
+ target: string;
51
+ linked: string[]; // names newly symlinked
52
+ skipped: string[]; // names already present (not ours to touch)
53
+ refreshed: string[]; // our own dangling/stale symlinks repointed
54
+ }
55
+
56
+ /**
57
+ * Symlink each entry of the bun-global bin dir into `target` (default
58
+ * /usr/local/bin). Skips a name that already exists UNLESS it's a symlink we
59
+ * previously created into the same bun dir (those we refresh, so a rebuilt/moved
60
+ * bun bin stays valid). Returns null on an unsupported platform or when neither
61
+ * the bun dir nor the target is usable. Never throws.
62
+ */
63
+ export function linkBunBinsToSystemPath(opts?: {
64
+ bunDir?: string | null;
65
+ target?: string;
66
+ env?: NodeJS.ProcessEnv;
67
+ }): LinkResult | null {
68
+ if (process.platform === "win32") return null;
69
+ const env = opts?.env ?? process.env;
70
+ const bunDir = opts?.bunDir ?? bunBinDir(env);
71
+ if (!bunDir) return null;
72
+ const target = opts?.target ?? "/usr/local/bin";
73
+
74
+ try {
75
+ if (!existsSync(target)) mkdirSync(target, { recursive: true });
76
+ } catch {
77
+ return null; // can't create the target dir (permissions) — best-effort give up
78
+ }
79
+
80
+ const result: LinkResult = { target, linked: [], skipped: [], refreshed: [] };
81
+ let entries: string[];
82
+ try {
83
+ entries = readdirSync(bunDir);
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ for (const name of entries) {
89
+ const src = path.join(bunDir, name);
90
+ const dest = path.join(target, name);
91
+ let existing: ReturnType<typeof lstatSync> | null = null;
92
+ try {
93
+ existing = lstatSync(dest);
94
+ } catch {
95
+ existing = null; // dest absent
96
+ }
97
+ if (existing) {
98
+ // Only touch a symlink WE own (points back into this bun dir). Anything
99
+ // else (a real binary, or a link elsewhere) is not ours to replace.
100
+ let ours = false;
101
+ if (existing.isSymbolicLink()) {
102
+ try {
103
+ ours = path.resolve(target, readlinkSync(dest)) === src;
104
+ } catch {
105
+ ours = false;
106
+ }
107
+ }
108
+ if (ours) {
109
+ result.skipped.push(name); // already correctly linked — nothing to do
110
+ } else if (existing.isSymbolicLink() && !existsSync(dest) && danglingPointsInto(dest, bunDir)) {
111
+ // A dangling symlink that USED to point into our bun dir (a bin that
112
+ // moved/rebuilt) — repoint it. We check the old target dir so we never
113
+ // hijack an unrelated broken link that merely shares a name.
114
+ try {
115
+ unlinkSync(dest);
116
+ symlinkSync(src, dest);
117
+ result.refreshed.push(name);
118
+ } catch {
119
+ result.skipped.push(name);
120
+ }
121
+ } else {
122
+ result.skipped.push(name); // a real/foreign entry — never clobber
123
+ }
124
+ continue;
125
+ }
126
+ try {
127
+ symlinkSync(src, dest);
128
+ result.linked.push(name);
129
+ } catch {
130
+ result.skipped.push(name); // permission/race — best effort
131
+ }
132
+ }
133
+ return result;
134
+ }
@@ -1,11 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./versionChecker-YDl8a9il.js";
3
- import "./ts-D-E4xqpA.js";
4
- import "./todoAutomation-BQRW614S.js";
5
- import "./JsonlStore-CPGIR0Wy.js";
6
- import "./globalPidIndex-Dq4KY2IV.js";
7
- import "./pidStore-Cmcc8Oej.js";
8
- import "./messageLog-BYFwioFY.js";
9
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DD6bhkyI.js";
10
-
11
- export { SUPPORTED_CLIS };
@@ -1,4 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import { a as updateGlobalPidStatus, i as readGlobalPids, n as maybeCompactGlobalPids, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-Dq4KY2IV.js";
3
-
4
- export { readGlobalPids };
@@ -1,6 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./JsonlStore-CPGIR0Wy.js";
3
- import "./globalPidIndex-Dq4KY2IV.js";
4
- import { t as PidStore } from "./pidStore-Cmcc8Oej.js";
5
-
6
- export { PidStore };
@@ -1,5 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./globalPidIndex-Dq4KY2IV.js";
3
- import { i as waitForSpawnCapacity, n as spawnGateEnabled, r as spawnRejectionReason, t as memAvailableMb } from "./spawnGate-D0E1uRP7.js";
4
-
5
- export { waitForSpawnCapacity };
@@ -1,5 +0,0 @@
1
- import "./versionChecker-YDl8a9il.js";
2
- import "./rustBinary-orhBmTNo.js";
3
- import { a as trayHiddenMarker, i as launchTray, n as hasDesktop, r as isTrayHidden, t as cmdTray } from "./trayApp-4sZgxxP-.js";
4
-
5
- export { cmdTray };