agent-yes 1.263.0 → 1.264.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 (49) hide show
  1. package/dist/SUPPORTED_CLIS-DlxOPg-j.js +11 -0
  2. package/dist/{SUPPORTED_CLIS-DjFlWHpg.js → SUPPORTED_CLIS-JcVs6jt7.js} +2 -2
  3. package/dist/{agentShare-BP6wiu_5.js → agentShare-B8h_JkOw.js} +3 -3
  4. package/dist/{callback-B0PQUZcK.js → callback-C9rkbjgR.js} +3 -3
  5. package/dist/{callback-DFUFLIZQ.js → callback-DlJ_glcF.js} +3 -3
  6. package/dist/cli.js +9 -9
  7. package/dist/{cwdConflictWarn-B_047KCF.js → cwdConflictWarn-DZTxTGXq.js} +2 -2
  8. package/dist/{forkNested-CBdhC852.js → forkNested-CgJ1rTnT.js} +2 -2
  9. package/dist/globalPidIndex-CQX_0JSB.js +4 -0
  10. package/dist/{globalPidIndex-pwp7Vb7o.js → globalPidIndex-Dq4KY2IV.js} +2 -8
  11. package/dist/index.js +4 -4
  12. package/dist/{notifyDaemon-BskLhHdg.js → notifyDaemon--TDDJupp.js} +3 -3
  13. package/dist/pidStore-D-b8I6Qx.js +6 -0
  14. package/dist/{pidStore-DfSm1jog.js → pidStore-DJqSZPJi.js} +11 -7
  15. package/dist/{rustBinary-Borvwmya.js → rustBinary-DA-C6PjH.js} +2 -2
  16. package/dist/{schedule-BSEIVKQ0.js → schedule-CdgYYRti.js} +6 -6
  17. package/dist/{serve-BEdLsSpv.js → serve-Z8FAjest.js} +20 -20
  18. package/dist/{setup-DFRvZ_ku.js → setup-D6B85mUe.js} +2 -2
  19. package/dist/{spawnGate-D-68B907.js → spawnGate-D0E1uRP7.js} +2 -2
  20. package/dist/spawnGate-DqKBA07k.js +5 -0
  21. package/dist/{subcommands-B1YlmRaZ.js → subcommands-C2ipqBtq.js} +2 -2
  22. package/dist/{subcommands-Bo1MSwuC.js → subcommands-C3w4bg6x.js} +134 -90
  23. package/dist/{terminal-BMQWojIp.js → terminal-iObUHvkj.js} +2 -2
  24. package/dist/{todoCli-DNOk38lq.js → todoCli-CngDeaT2.js} +2 -2
  25. package/dist/{trayApp-CjB5QhdV.js → trayApp-BUI7SXiY.js} +2 -2
  26. package/dist/trayApp-Cb9a-quB.js +5 -0
  27. package/dist/{ts-BSHR_dga.js → ts-ClHKe4d8.js} +123 -5
  28. package/dist/{versionChecker-BTLZ3PAb.js → versionChecker-DRAbaxo-.js} +2 -2
  29. package/dist/{widget-BVss0Z9Y.js → widget-BRBsnbLn.js} +3 -3
  30. package/dist/{ws-DMudFezi.js → ws-BppNz49U.js} +3 -3
  31. package/dist/{ws-DxNNwnrc.js → ws-CAFA4oXH.js} +2 -2
  32. package/package.json +1 -1
  33. package/ts/globalPidIndex.spec.ts +5 -20
  34. package/ts/globalPidIndex.ts +9 -19
  35. package/ts/identity.spec.ts +108 -0
  36. package/ts/identity.ts +124 -0
  37. package/ts/index.ts +12 -4
  38. package/ts/messageLog.ts +0 -2
  39. package/ts/pidStore.spec.ts +14 -24
  40. package/ts/pidStore.ts +13 -10
  41. package/ts/subcommands.spec.ts +11 -120
  42. package/ts/subcommands.ts +13 -103
  43. package/ts/titleScanner.spec.ts +65 -0
  44. package/ts/titleScanner.ts +137 -0
  45. package/dist/SUPPORTED_CLIS--5E9wO4L.js +0 -11
  46. package/dist/globalPidIndex-CHA__9Xf.js +0 -4
  47. package/dist/pidStore-1PQ4ARnh.js +0 -6
  48. package/dist/spawnGate-DbSHnfqA.js +0 -5
  49. package/dist/trayApp-DrdktFnW.js +0 -5
@@ -2670,124 +2670,11 @@ describe("subcommands.cmdWhoami", () => {
2670
2670
  expect(text).toMatch(/agent {5}claude #\d+ {2}\(agent_id aaaa0000bbbb\)/);
2671
2671
  expect(text).toMatch(/reply {5}ay send aaaa0000bbbb/);
2672
2672
  expect(text).toMatch(
2673
- /<ay-msg from claude #\d+ @ \/repo\/alpha — reply: ay send aaaa0000bbbb "\.\.\.">/,
2673
+ /<ay-msg from claude [A-Za-z0-9._-]+@[A-Za-z0-9._-]+:\/repo\/alpha#\d+ — reply: ay send aaaa0000bbbb "\.\.\.">/,
2674
2674
  );
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
-
2791
2678
  describe("subcommands.cmdSend double-envelope warning", () => {
2792
2679
  const itUnix = process.platform === "linux" || process.platform === "darwin";
2793
2680
 
@@ -2859,7 +2746,7 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2859
2746
  const buf = Buffer.alloc(8192);
2860
2747
  const n = fs.readSync(rdwrFd, buf, 0, buf.length, null);
2861
2748
  const received = buf.subarray(0, n).toString();
2862
- expect(received).toMatch(/^<ay-msg [0-9a-f]{8} from claude #900001 @ /);
2749
+ expect(received).toMatch(/^<ay-msg [0-9a-f]{8} from claude [A-Za-z0-9._-]+@[A-Za-z0-9._-]+:\/repo\/beta#900001 — /);
2863
2750
  expect(received).toContain("<ay-msg deadbeef");
2864
2751
  fs.closeSync(rdwrFd);
2865
2752
  } finally {
@@ -2933,16 +2820,20 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2933
2820
  }
2934
2821
  });
2935
2822
 
2936
- it.skipIf(!itUnix)("a sender with a role gets it rendered into the envelope header", async () => {
2823
+ it.skipIf(!itUnix)("the envelope header carries the standardized identity", async () => {
2937
2824
  const { runSubcommand } = await loadModule();
2938
2825
  const { appendGlobalPid } = await import("./globalPidIndex.ts");
2939
2826
  const { spawnSync } = await import("child_process");
2940
2827
  const tmp = await mkdtemp(path.join(tmpdir(), "ay-fifo-"));
2941
2828
  try {
2942
- const fifo = path.join(tmp, "role.fifo");
2829
+ const fifo = path.join(tmp, "ident.fifo");
2943
2830
  if (spawnSync("mkfifo", [fifo]).status !== 0) return;
2944
2831
  const fs = await import("fs");
2945
2832
  const rdwrFd = fs.openSync(fifo, fs.constants.O_RDWR);
2833
+ // Sender cwd IS a git checkout, so the identity carries its branch.
2834
+ const senderCwd = path.join(tmp, "sender-repo");
2835
+ await mkdir(path.join(senderCwd, ".git"), { recursive: true });
2836
+ await writeFile(path.join(senderCwd, ".git", "HEAD"), "ref: refs/heads/crm-lane\n");
2946
2837
  await appendGlobalPid({
2947
2838
  pid: process.pid,
2948
2839
  cli: "claude",
@@ -2959,7 +2850,7 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2959
2850
  pid: 900001,
2960
2851
  cli: "claude",
2961
2852
  prompt: null,
2962
- cwd: "/repo/beta",
2853
+ cwd: senderCwd,
2963
2854
  log_file: null,
2964
2855
  status: "active",
2965
2856
  exit_code: null,
@@ -2967,7 +2858,6 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2967
2858
  started_at: Date.now(),
2968
2859
  wrapper_pid: 424242,
2969
2860
  agent_id: "cccc0000dddd",
2970
- role: "crm",
2971
2861
  });
2972
2862
  const savedAyPid = process.env.AGENT_YES_PID;
2973
2863
  process.env.AGENT_YES_PID = "424242";
@@ -2991,8 +2881,9 @@ describe("subcommands.cmdSend double-envelope warning", () => {
2991
2881
  const buf = Buffer.alloc(4096);
2992
2882
  const n = fs.readSync(rdwrFd, buf, 0, buf.length, null);
2993
2883
  const received = buf.subarray(0, n).toString();
2884
+ // <user>@<host>:<path>:<branch>#<pid> — reply targets the agent_id.
2994
2885
  expect(received).toMatch(
2995
- /^<ay-msg [0-9a-f]{8} from claude #900001 \(crm\) @ \/repo\/beta — reply: ay send cccc0000dddd "\.\.\.">/,
2886
+ /^<ay-msg [0-9a-f]{8} from claude [A-Za-z0-9._-]+@[A-Za-z0-9._-]+:.+:crm-lane#900001 — reply: ay send cccc0000dddd "\.\.\.">/,
2996
2887
  );
2997
2888
  fs.closeSync(rdwrFd);
2998
2889
  } finally {
package/ts/subcommands.ts CHANGED
@@ -18,12 +18,8 @@ import { fileURLToPath } from "node:url";
18
18
  import ms from "ms";
19
19
  import { homedir } from "os";
20
20
  import path from "path";
21
- import {
22
- type GlobalPidRecord,
23
- readGlobalPids,
24
- sanitizeRole,
25
- updateGlobalPidStatus,
26
- } from "./globalPidIndex.ts";
21
+ import { type GlobalPidRecord, readGlobalPids, updateGlobalPidStatus } from "./globalPidIndex.ts";
22
+ import { formatIdentity } from "./identity.ts";
27
23
  import { buildAgentForest, flattenForest } from "./agentTree.ts";
28
24
  import { parseTaskCounts, type TaskCounts } from "./todoParse.ts";
29
25
  import { agentYesHome } from "./agentYesHome.ts";
@@ -328,7 +324,8 @@ async function cmdWhoami(rest: string[]): Promise<number> {
328
324
  const ageMin = Math.max(0, Math.round((Date.now() - self.started_at) / 60_000));
329
325
  const lines = [
330
326
  `agent ${self.cli} #${self.pid}${self.agent_id ? ` (agent_id ${self.agent_id})` : ""}`,
331
- `role ${self.role ?? "- (set with: ay role <name>)"}`,
327
+ `identity ${formatIdentity({ cwd: self.cwd, pid: self.pid })}`,
328
+ `title ${self.title ?? "-"}`,
332
329
  `state ${state}${question ? ` — ${question}` : ""}`,
333
330
  `cwd ${self.cwd}`,
334
331
  `started ${new Date(self.started_at).toISOString()} (${ageMin}m ago)`,
@@ -336,7 +333,7 @@ async function cmdWhoami(rest: string[]): Promise<number> {
336
333
  `log ${self.log_file ?? "-"}`,
337
334
  `fifo ${self.fifo_file ?? "-"}`,
338
335
  `reply ${reply} "..."`,
339
- `envelope <ay-msg from ${self.cli} #${self.pid}${self.role ? ` (${self.role})` : ""} @ ${self.cwd} — reply: ${reply} "...">…</ay-msg>`,
336
+ `envelope <ay-msg from ${self.cli} ${formatIdentity({ cwd: self.cwd, pid: self.pid })} — reply: ${reply} "...">…</ay-msg>`,
340
337
  ];
341
338
  process.stdout.write(lines.join("\n") + "\n");
342
339
  return 0;
@@ -387,7 +384,7 @@ async function readLocalTsPids(cwd: string): Promise<GlobalPidRecord[]> {
387
384
  exit_code: d.exitCode ?? null,
388
385
  exit_reason: d.exitReason ?? null,
389
386
  started_at: d.startedAt ?? 0,
390
- role: d.role ?? null,
387
+ title: d.title ?? null,
391
388
  }));
392
389
  }
393
390
 
@@ -422,7 +419,6 @@ const SUBCOMMANDS = new Set([
422
419
  "head",
423
420
  "send",
424
421
  "msgs",
425
- "role",
426
422
  "key",
427
423
  "select",
428
424
  "spawn",
@@ -593,8 +589,6 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
593
589
  return await cmdSend(rest);
594
590
  case "msgs":
595
591
  return await cmdMsgs(rest);
596
- case "role":
597
- return await cmdRole(rest);
598
592
  case "key":
599
593
  return await cmdKey(rest);
600
594
  case "select":
@@ -815,7 +809,6 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
815
809
  ` ay head <keyword> first N lines\n` +
816
810
  ` ay send <keyword> <msg> send a message (keyword '.' = agent in this cwd)\n` +
817
811
  ` ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n` +
818
- ` ay role [name] | ay role <kw> <name> lane/role name shown in the send envelope\n` +
819
812
  ` ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n` +
820
813
  ` ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n` +
821
814
  ` ay widget ls | read selection|dom read an opted-in page widget's context (selection/DOM) (ay widget help)\n` +
@@ -3429,13 +3422,13 @@ async function cmdSend(rest: string[]): Promise<number> {
3429
3422
  let nonce: string | undefined;
3430
3423
  if (sender.agent && !isSlashCommand(body) && !raw) {
3431
3424
  nonce = randomBytes(4).toString("hex");
3432
- // The sender's self-declared role ("pm", "crm", …) rides along when set —
3433
- // recipients read dozens of these a day and a pid/cwd doesn't carry the
3434
- // lane name (cwd → role isn't always derivable). sanitizeRole guarantees
3435
- // the value can't forge header syntax.
3436
- const senderRole = sanitizeRole(sender.agent.role);
3437
- const roleTag = senderRole ? ` (${senderRole})` : "";
3438
- prefix = `<ay-msg ${nonce} from ${sender.agent.cli} #${sender.agent.pid}${roleTag} @ ${shortenPath(sender.agent.cwd)} — reply: ay send ${replyTarget} "...">\n`;
3425
+ // The standardized identity (<user>@<host>:<path>:<branch>#<pid>) names the
3426
+ // sender in one token: the branch usually carries the lane name for free
3427
+ // (worktree checkouts name their purpose), so recipients don't translate
3428
+ // cwd/pid into a role by hand. Every segment is clamped to a header-safe
3429
+ // charset in identity.ts — the open tag is not nonce-protected.
3430
+ const identity = formatIdentity({ cwd: sender.agent.cwd, pid: sender.agent.pid });
3431
+ prefix = `<ay-msg ${nonce} from ${sender.agent.cli} ${identity} — reply: ay send ${replyTarget} "...">\n`;
3439
3432
  suffix = `\n</ay-msg ${nonce}>`;
3440
3433
  // A body that itself starts with an <ay-msg …> header is usually an agent
3441
3434
  // hand-wrapping what this send is about to wrap again — the recipient then
@@ -3516,7 +3509,6 @@ async function cmdSend(rest: string[]): Promise<number> {
3516
3509
  cli: sender.agent.cli,
3517
3510
  cwd: sender.agent.cwd,
3518
3511
  agent_id: sender.agent.agent_id,
3519
- role: sender.agent.role ?? undefined,
3520
3512
  }
3521
3513
  : null,
3522
3514
  to: {
@@ -3524,7 +3516,6 @@ async function cmdSend(rest: string[]): Promise<number> {
3524
3516
  cli: record.cli,
3525
3517
  cwd: record.cwd,
3526
3518
  agent_id: record.agent_id,
3527
- role: record.role ?? undefined,
3528
3519
  },
3529
3520
  body,
3530
3521
  code: trailing === "\r" ? undefined : codeName,
@@ -3696,87 +3687,6 @@ async function cmdMsgs(rest: string[]): Promise<number> {
3696
3687
  return 0;
3697
3688
  }
3698
3689
 
3699
- /**
3700
- * `ay role [name]` / `ay role <keyword> <name>` — read or set an agent's
3701
- * self-declared lane/role name ("pm", "crm", …). The role rides in the
3702
- * `ay send` envelope header (`from claude #123 (pm) @ …`) so recipients don't
3703
- * translate cwd/pid into a lane by hand — the emergent `[pm→qa]` body prefixes
3704
- * exist exactly because the envelope lacked this. With no args: print the
3705
- * calling agent's role. With one arg: set the calling agent's role (requires
3706
- * agent context). With two args: resolve the keyword and set that agent's role.
3707
- * `--clear` removes it. Roles are clamped by sanitizeRole (they render into the
3708
- * non-nonce-protected open tag).
3709
- */
3710
- const ROLE_RESOLVE_OPTS: CommonOpts = {
3711
- all: false,
3712
- active: false,
3713
- cwdScope: null,
3714
- latest: false,
3715
- json: false,
3716
- };
3717
-
3718
- async function cmdRole(rest: string[]): Promise<number> {
3719
- const y = yargs(rest)
3720
- .usage('Usage: ay role [name] | ay role <keyword> <name> | ay role [keyword] --clear')
3721
- .option("clear", { type: "boolean", default: false, description: "Remove the role" })
3722
- .help(false)
3723
- .version(false)
3724
- .exitProcess(false);
3725
- const argv = await y.parseAsync();
3726
- const args = argv._.slice(0).map(String);
3727
-
3728
- // Figure out target + new value from arity: 0 args = self get (or self clear),
3729
- // 1 arg = self set (or keyword clear), 2 args = keyword set.
3730
- let target: GlobalPidRecord | null = null;
3731
- let newRole: string | undefined;
3732
- if (args.length === 2) {
3733
- target = await resolveOne(args[0]!, ROLE_RESOLVE_OPTS);
3734
- newRole = args[1]!;
3735
- } else if (args.length === 1 && argv.clear) {
3736
- target = await resolveOne(args[0]!, ROLE_RESOLVE_OPTS);
3737
- } else if (args.length === 1) {
3738
- target = await resolveSender();
3739
- if (!target) {
3740
- process.stderr.write(
3741
- "ay role: no agent context (AGENT_YES_PID unset or unregistered) — from a human shell, name the agent: ay role <keyword> <name>\n",
3742
- );
3743
- return 1;
3744
- }
3745
- newRole = args[0]!;
3746
- } else {
3747
- target = await resolveSender();
3748
- if (!target) {
3749
- process.stderr.write(
3750
- "ay role: no agent context — from a human shell, name the agent: ay role <keyword> [<name>|--clear]\n",
3751
- );
3752
- return 1;
3753
- }
3754
- }
3755
-
3756
- if (newRole === undefined && !argv.clear) {
3757
- process.stdout.write(`${target.role ?? ""}\n`);
3758
- return 0;
3759
- }
3760
-
3761
- let role: string | null = null;
3762
- if (!argv.clear) {
3763
- role = sanitizeRole(newRole);
3764
- if (!role) {
3765
- process.stderr.write(
3766
- `ay role: invalid role ${JSON.stringify(newRole)} — use 1-32 chars of [A-Za-z0-9._-]\n`,
3767
- );
3768
- return 1;
3769
- }
3770
- }
3771
- await updateGlobalPidStatus(target.pid, { role });
3772
- process.stdout.write(
3773
- role
3774
- ? `pid ${target.pid} (${target.cli}): role set to "${role}" — envelopes now read: from ${target.cli} #${target.pid} (${role}) @ ${shortenPath(target.cwd)}\n`
3775
- : `pid ${target.pid} (${target.cli}): role cleared\n`,
3776
- );
3777
- return 0;
3778
- }
3779
-
3780
3690
  // Resolve a keyword to one agent and return it with a writable FIFO, or throw
3781
3691
  // with the same guidance cmdSend gives. Shared by `ay key` / `ay select`.
3782
3692
  async function resolveWritableAgent(keyword: string, opts: CommonOpts): Promise<GlobalPidRecord> {
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { TitlePublisher, TitleScanner } from "./titleScanner.ts";
3
+
4
+ describe("TitleScanner", () => {
5
+ it("parses OSC 0 terminated by BEL", () => {
6
+ const s = new TitleScanner();
7
+ expect(s.feed("\x1b]0;✳ fixing tests\x07")).toBe("✳ fixing tests");
8
+ });
9
+
10
+ it("parses OSC 2 terminated by ST (ESC \\)", () => {
11
+ const s = new TitleScanner();
12
+ expect(s.feed("\x1b]2;my task\x1b\\")).toBe("my task");
13
+ });
14
+
15
+ it("survives a chunk split mid-sequence", () => {
16
+ const s = new TitleScanner();
17
+ expect(s.feed("text \x1b]0;ha")).toBeNull();
18
+ expect(s.feed("lf done\x07 more")).toBe("half done");
19
+ });
20
+
21
+ it("returns the last title when a chunk has several", () => {
22
+ const s = new TitleScanner();
23
+ expect(s.feed("\x1b]0;one\x07\x1b]2;two\x07")).toBe("two");
24
+ });
25
+
26
+ it("ignores icon-only and unrelated OSC sequences", () => {
27
+ const s = new TitleScanner();
28
+ expect(s.feed("\x1b]1;icon\x07\x1b]52;c;YWJj\x07\x1b]133;A\x07")).toBeNull();
29
+ });
30
+
31
+ it("ignores CSI colors and plain text", () => {
32
+ const s = new TitleScanner();
33
+ expect(s.feed("plain \x1b[31mred\x1b[0m text")).toBeNull();
34
+ });
35
+
36
+ it("caps runaway titles and resyncs", () => {
37
+ const s = new TitleScanner();
38
+ expect(s.feed(`\x1b]0;${"x".repeat(2000)}\x07`)).toBeNull();
39
+ expect(s.feed("\x1b]0;ok\x07")).toBe("ok");
40
+ });
41
+
42
+ it("strips control chars and rejects empty titles", () => {
43
+ const s = new TitleScanner();
44
+ expect(s.feed("\x1b]0;a\tb\x07")).toBe("ab");
45
+ expect(s.feed("\x1b]0;\x07")).toBeNull();
46
+ expect(s.feed("\x1b]0; \x07")).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe("TitlePublisher", () => {
51
+ it("writes on change, dedupes, and rate-limits within the window", () => {
52
+ const writes: string[] = [];
53
+ const p = new TitlePublisher((t) => writes.push(t), 2000);
54
+ p.observe("a");
55
+ expect(writes).toEqual(["a"]); // first write is immediate
56
+ p.observe("a");
57
+ expect(writes).toEqual(["a"]); // unchanged — no write
58
+ p.observe("b");
59
+ expect(writes).toEqual(["a"]); // changed but throttled
60
+ p.poll(Date.now() + 2500); // window elapsed — pending title lands
61
+ expect(writes).toEqual(["a", "b"]);
62
+ p.poll(Date.now() + 5000);
63
+ expect(writes).toEqual(["a", "b"]); // nothing pending — no write
64
+ });
65
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Incremental scanner for terminal-title escapes (OSC 0/2) in the child CLI's
3
+ * PTY stream — the TS-runtime mirror of rs/src/title_scanner.rs; keep the two
4
+ * in sync.
5
+ *
6
+ * Coding-agent CLIs (claude, opencode, codex) continuously set the terminal
7
+ * title to a summary of what they are doing (`ESC ] 0 ; <title> BEL`, also
8
+ * terminated by `ESC \`). The wrapper observes the stream anyway, so scanning
9
+ * it yields a live "what is this agent doing" label for free — surfaced as
10
+ * `title` in the pid registry and shown by `ay whoami` / `ay ls --json`.
11
+ *
12
+ * Fed arbitrary chunk boundaries: a sequence split across reads still parses.
13
+ * The scanner only observes — it never modifies the stream.
14
+ */
15
+
16
+ /** A title longer than this is a runaway OSC without terminator — discard. */
17
+ const MAX_TITLE_LEN = 512;
18
+ /** What we store/display. */
19
+ const STORED_TITLE_LEN = 256;
20
+
21
+ const enum State {
22
+ Ground,
23
+ Esc,
24
+ OscParam,
25
+ OscTitle,
26
+ OscOther,
27
+ OscTitleEsc,
28
+ OscOtherEsc,
29
+ }
30
+
31
+ export class TitleScanner {
32
+ private state = State.Ground;
33
+ private param = "";
34
+ private title = "";
35
+
36
+ /** Feed one output chunk; returns the LAST complete title in it, if any. */
37
+ feed(chunk: string): string | null {
38
+ let found: string | null = null;
39
+ for (const c of chunk) {
40
+ switch (this.state) {
41
+ case State.Ground:
42
+ if (c === "\x1b") this.state = State.Esc;
43
+ break;
44
+ case State.Esc:
45
+ if (c === "]") {
46
+ this.param = "";
47
+ this.state = State.OscParam;
48
+ } else if (c !== "\x1b") {
49
+ this.state = State.Ground;
50
+ }
51
+ break;
52
+ case State.OscParam:
53
+ if (c >= "0" && c <= "9" && this.param.length < 4) {
54
+ this.param += c;
55
+ } else if (c === ";") {
56
+ // OSC 0 (icon+title) and 2 (title) carry the window title; OSC 1
57
+ // is icon-only and everything else (8, 52, 133, …) is unrelated.
58
+ if (this.param === "0" || this.param === "2") {
59
+ this.title = "";
60
+ this.state = State.OscTitle;
61
+ } else {
62
+ this.state = State.OscOther;
63
+ }
64
+ } else if (c === "\x07") {
65
+ this.state = State.Ground;
66
+ } else if (c === "\x1b") {
67
+ this.state = State.OscOtherEsc;
68
+ } else {
69
+ this.state = State.OscOther;
70
+ }
71
+ break;
72
+ case State.OscTitle:
73
+ if (c === "\x07") {
74
+ found = cleanTitle(this.title);
75
+ this.state = State.Ground;
76
+ } else if (c === "\x1b") {
77
+ this.state = State.OscTitleEsc;
78
+ } else if (this.title.length >= MAX_TITLE_LEN) {
79
+ this.state = State.OscOther; // runaway — drop and resync
80
+ } else {
81
+ this.title += c;
82
+ }
83
+ break;
84
+ case State.OscTitleEsc:
85
+ if (c === "\\") found = cleanTitle(this.title);
86
+ this.state = State.Ground; // a bare ESC aborts the OSC either way
87
+ break;
88
+ case State.OscOther:
89
+ if (c === "\x07") this.state = State.Ground;
90
+ else if (c === "\x1b") this.state = State.OscOtherEsc;
91
+ break;
92
+ case State.OscOtherEsc:
93
+ this.state = State.Ground;
94
+ break;
95
+ }
96
+ }
97
+ return found || null;
98
+ }
99
+ }
100
+
101
+ /** Strip control chars (they can't be typed into a title bar) and clamp. */
102
+ function cleanTitle(raw: string): string {
103
+ // eslint-disable-next-line no-control-regex
104
+ const trimmed = raw.replace(/[\x00-\x1f\x7f]/g, "").trim();
105
+ return trimmed.slice(0, STORED_TITLE_LEN);
106
+ }
107
+
108
+ /**
109
+ * Change-gated + rate-limited publisher: registry writes take the
110
+ * cross-runtime lock, while claude retitles every few seconds — the steady
111
+ * state must cost zero writes, and a retitle burst collapses to one write per
112
+ * window. Call `poll()` from any recurring point in the run loop so the final
113
+ * title still lands within a window after the CLI goes quiet.
114
+ */
115
+ export class TitlePublisher {
116
+ private latest: string | null = null;
117
+ private written: string | null = null;
118
+ private writtenAt = 0;
119
+
120
+ constructor(
121
+ private readonly write: (title: string) => void,
122
+ private readonly minIntervalMs = 2_000,
123
+ ) {}
124
+
125
+ observe(title: string): void {
126
+ this.latest = title;
127
+ this.poll();
128
+ }
129
+
130
+ poll(now: number = Date.now()): void {
131
+ if (this.latest === null || this.latest === this.written) return;
132
+ if (now - this.writtenAt < this.minIntervalMs) return;
133
+ this.write(this.latest);
134
+ this.written = this.latest;
135
+ this.writtenAt = now;
136
+ }
137
+ }
@@ -1,11 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./versionChecker-BTLZ3PAb.js";
3
- import "./ts-BSHR_dga.js";
4
- import "./todoAutomation-BQRW614S.js";
5
- import "./JsonlStore-CPGIR0Wy.js";
6
- import "./globalPidIndex-pwp7Vb7o.js";
7
- import "./pidStore-DfSm1jog.js";
8
- import "./messageLog-BYFwioFY.js";
9
- import { t as SUPPORTED_CLIS } from "./SUPPORTED_CLIS-DjFlWHpg.js";
10
-
11
- export { SUPPORTED_CLIS };
@@ -1,4 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import { a as sanitizeRole, i as readGlobalPids, n as maybeCompactGlobalPids, o as updateGlobalPidStatus, r as pruneOldLogs, t as appendGlobalPid } from "./globalPidIndex-pwp7Vb7o.js";
3
-
4
- export { readGlobalPids };
@@ -1,6 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./JsonlStore-CPGIR0Wy.js";
3
- import "./globalPidIndex-pwp7Vb7o.js";
4
- import { t as PidStore } from "./pidStore-DfSm1jog.js";
5
-
6
- export { PidStore };
@@ -1,5 +0,0 @@
1
- import "./logger-CDIsZ-Pp.js";
2
- import "./globalPidIndex-pwp7Vb7o.js";
3
- import { i as waitForSpawnCapacity, n as spawnGateEnabled, r as spawnRejectionReason, t as memAvailableMb } from "./spawnGate-D-68B907.js";
4
-
5
- export { waitForSpawnCapacity };
@@ -1,5 +0,0 @@
1
- import "./versionChecker-BTLZ3PAb.js";
2
- import "./rustBinary-Borvwmya.js";
3
- import { a as trayHiddenMarker, i as launchTray, n as hasDesktop, r as isTrayHidden, t as cmdTray } from "./trayApp-CjB5QhdV.js";
4
-
5
- export { cmdTray };