agent-yes 1.290.7 → 1.291.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 (82) hide show
  1. package/dist/SUPPORTED_CLIS-BZP767UR.js +12 -0
  2. package/dist/{SUPPORTED_CLIS-hTRegnm5.js → SUPPORTED_CLIS-TiOdGi1h.js} +2 -2
  3. package/dist/{agentShare-C_LTEKEb.js → agentShare-D4N8StHO.js} +8 -8
  4. package/dist/{askCli-DR9vhdcV.js → askCli-DFsWcgpF.js} +3 -3
  5. package/dist/{browser-oqC3Ln2a.js → browser-wkzxw6lx.js} +3 -2
  6. package/dist/{callback-DAQ8wCtJ.js → callback-Bc2gnr_f.js} +3 -3
  7. package/dist/{callback-BEvLUUBR.js → callback-yCpK6Qpg.js} +7 -7
  8. package/dist/{channels-hOG3-R_3.js → channels-aYwLMbFV.js} +2 -2
  9. package/dist/cli.js +9 -9
  10. package/dist/{cmdPs-BAaLB4Pz.js → cmdPs-D5L3Eyh5.js} +7 -7
  11. package/dist/{cwdConflictWarn-BgXN56Jc.js → cwdConflictWarn-sQUONAye.js} +2 -2
  12. package/dist/{forkNested-CJ7lQQCe.js → forkNested-xtEeYpBQ.js} +3 -3
  13. package/dist/{globalPidIndex-DoaKEUkO.js → globalPidIndex-CRH9L9tn.js} +139 -12
  14. package/dist/globalPidIndex-DC93JhHQ.js +4 -0
  15. package/dist/index.js +5 -5
  16. package/dist/{messageLog-CVRSTLza.js → messageLog-Brx3w6zT.js} +2 -1
  17. package/dist/{notifyDaemon-Dnbe66Fl.js → notifyDaemon-CvUtRnAB.js} +7 -7
  18. package/dist/{parentLink-fdiuB13i.js → parentLink-BILJ32nA.js} +2 -2
  19. package/dist/{parentLink-bpDGuQi6.js → parentLink-CfbxykCv.js} +2 -2
  20. package/dist/{parentPingLoop-DWv-xJoi.js → parentPingLoop-CVOCiQp2.js} +4 -4
  21. package/dist/pidStore-Bpe8i_oU.js +6 -0
  22. package/dist/{pidStore-B73xnAeT.js → pidStore-DXWqzTSz.js} +2 -2
  23. package/dist/{remotes-DTXPW8U1.js → remotes-tmKwrr20.js} +3 -3
  24. package/dist/{remotes-C696AJzL.js → remotes-wPL0JtBs.js} +2 -2
  25. package/dist/{rustBinary-CZRPfa4y.js → rustBinary-C_EbyMTk.js} +2 -2
  26. package/dist/{rustBinary-jO6EN8LR.js → rustBinary-Clbk_K5c.js} +2 -2
  27. package/dist/{schedule-Hao0WqyJ.js → schedule-Ba_4wNVK.js} +7 -7
  28. package/dist/{serve-B2nisui8.js → serve-CJDQq_Jp.js} +46 -40
  29. package/dist/{setup-vCDTqyqR.js → setup-DwLAkbTC.js} +2 -2
  30. package/dist/share-Bmnd_qT0.js +4 -0
  31. package/dist/{share-DTlaFn6s.js → share-C9oGvjXT.js} +13 -4
  32. package/dist/spawnGate-BUEswFgo.js +5 -0
  33. package/dist/{spawnGate-B26UnzqP.js → spawnGate-YpHopveO.js} +2 -2
  34. package/dist/{subcommands-BpFdlCY0.js → subcommands-x8Pe7DrF.js} +6 -6
  35. package/dist/{subcommands-D6LBtVPM.js → subcommands-yWgVRM2r.js} +273 -45
  36. package/dist/{terminal-iGHEnc7Z.js → terminal-Dv73BTE-.js} +2 -2
  37. package/dist/terminal.js +1 -1
  38. package/dist/{todoCli-azqbjBtz.js → todoCli-DSQXAcwA.js} +3 -3
  39. package/dist/{todoIdentity-NEceTmEe.js → todoIdentity-CAmnkzzP.js} +2 -2
  40. package/dist/{trayApp-BdNlQCFx.js → trayApp-BRw8f0mT.js} +2 -2
  41. package/dist/trayApp-BjQ5V3WV.js +5 -0
  42. package/dist/{ts-oyuaLWWx.js → ts-C8PxhJZM.js} +7 -7
  43. package/dist/{versionChecker-CMjS88mL.js → versionChecker-CWPGASjD.js} +2 -2
  44. package/dist/webrtcLink-CnQf9pmu.js +73 -0
  45. package/dist/{webrtcRemote-L5NxhLLR.js → webrtcRemote-yvMYsij3.js} +2 -2
  46. package/dist/{widget-OvkqW2Z6.js → widget-DhBSNUey.js} +3 -3
  47. package/dist/widgets.js +1 -1
  48. package/dist/{ws-BeMouYAU.js → ws-CMX0pkti.js} +2 -2
  49. package/dist/{ws-qZy7Giub.js → ws-DZ9YAXlY.js} +7 -7
  50. package/lab/ui/console-logic.js +27 -4
  51. package/package.json +1 -1
  52. package/ts/crossTreeClaim.bun.spec.ts +76 -0
  53. package/ts/crossTreeClaim.spec.ts +76 -0
  54. package/ts/globalPidIndex.spec.ts +153 -0
  55. package/ts/globalPidIndex.ts +151 -10
  56. package/ts/messageLog.bun.spec.ts +33 -0
  57. package/ts/messageLog.spec.ts +33 -0
  58. package/ts/messageLog.ts +3 -0
  59. package/ts/rawInputDeclared.bun.spec.ts +68 -0
  60. package/ts/rawInputDeclared.spec.ts +68 -0
  61. package/ts/remoteHealth.bun.spec.ts +130 -0
  62. package/ts/remoteHealth.spec.ts +130 -0
  63. package/ts/remoteHealth.ts +149 -0
  64. package/ts/restartKillGuard.bun.spec.ts +40 -0
  65. package/ts/restartKillGuard.spec.ts +40 -0
  66. package/ts/senderAncestry.ts +45 -7
  67. package/ts/serve.ts +46 -20
  68. package/ts/share.bun.spec.ts +24 -2
  69. package/ts/share.spec.ts +24 -2
  70. package/ts/share.ts +37 -5
  71. package/ts/subcommands.ts +239 -19
  72. package/ts/terminal/browser.ts +5 -1
  73. package/ts/webrtcLink.bun.spec.ts +45 -1
  74. package/ts/webrtcLink.spec.ts +45 -1
  75. package/ts/webrtcLink.ts +63 -19
  76. package/dist/SUPPORTED_CLIS-CuvT3tKq.js +0 -12
  77. package/dist/globalPidIndex-yvWZXeWT.js +0 -4
  78. package/dist/pidStore-V51mbgZY.js +0 -6
  79. package/dist/share-BvU4I8BI.js +0 -4
  80. package/dist/spawnGate-C8MSRt7X.js +0 -5
  81. package/dist/trayApp-Br2T2Yrb.js +0 -5
  82. package/dist/webrtcLink-DLaJfTcB.js +0 -45
@@ -0,0 +1,73 @@
1
+ import { u as parseSecret } from "./e2e-z1xpwSAg.js";
2
+
3
+ //#region ts/webrtcLink.ts
4
+ const SIGNAL_SUBPROTOCOL = "ay-signal-1";
5
+ const DEFAULT_SIGHOST = "s.agent-yes.com";
6
+ function parseFragment(frag) {
7
+ if (/^\??[A-Za-z_]+=/.test(frag)) {
8
+ const q = new URLSearchParams(frag.replace(/^\?/, ""));
9
+ const room = q.get("room");
10
+ const token = q.get("s");
11
+ if (!room || !token) return null;
12
+ return {
13
+ room,
14
+ token,
15
+ host: q.get("sig") || DEFAULT_SIGHOST
16
+ };
17
+ }
18
+ const at = frag.split("@");
19
+ const seg = at[0];
20
+ const i = seg.indexOf(":");
21
+ if (i < 0) return null;
22
+ return {
23
+ room: seg.slice(0, i),
24
+ token: seg.slice(i + 1),
25
+ host: at[1] || DEFAULT_SIGHOST
26
+ };
27
+ }
28
+ /** Parse a share link WITHOUT validating/stripping the secret's version marker. */
29
+ function parseRaw(link) {
30
+ const wr = /^webrtc:\/\/([^:]+):([^@]+)@(.+)$/.exec(link);
31
+ if (wr) return {
32
+ room: wr[1],
33
+ token: wr[2],
34
+ host: wr[3]
35
+ };
36
+ if (/^https?:\/\//.test(link) && link.includes("#")) return parseFragment(link.split("#")[1] ?? "");
37
+ return null;
38
+ }
39
+ /**
40
+ * Parse a share link into { room, secret, signaling-host }. Accepts:
41
+ * webrtc://<room>:<token>@<host> (internal / legacy form)
42
+ * https://<anyhost>/room/#room=<id>&s=<token>[&sig=<host>]
43
+ * https://<anyhost>/w/#<room>:<token>[@<sighost>] (older positional form)
44
+ * Returns null if the string isn't a recognizable share link.
45
+ */
46
+ function parseWebrtcLink(link) {
47
+ const raw = parseRaw(link);
48
+ if (!raw) return null;
49
+ const { s } = parseSecret(raw.token);
50
+ return {
51
+ room: raw.room,
52
+ s,
53
+ host: raw.host
54
+ };
55
+ }
56
+ /**
57
+ * Normalize any accepted share-link spelling to the internal
58
+ * `webrtc://<room>:<token>@<host>` form that startShare/parseShareUrl consume.
59
+ * Returns the RAW token, so a legacy markerless room round-trips unchanged
60
+ * instead of being handed a marker it never had.
61
+ */
62
+ function toWebrtcUrl(link) {
63
+ const raw = parseRaw(link);
64
+ return raw ? `webrtc://${raw.room}:${raw.token}@${raw.host}` : null;
65
+ }
66
+ /** True if `spec` looks like a WebRTC share link (vs. an http remote or alias). */
67
+ function isWebrtcSpec(spec) {
68
+ return spec.startsWith("webrtc://") || /^https?:\/\//.test(spec) && spec.includes("#");
69
+ }
70
+
71
+ //#endregion
72
+ export { toWebrtcUrl as i, isWebrtcSpec as n, parseWebrtcLink as r, SIGNAL_SUBPROTOCOL as t };
73
+ //# sourceMappingURL=webrtcLink-CnQf9pmu.js.map
@@ -1,5 +1,5 @@
1
1
  import { a as computeTranscriptHash, c as open, d as randomHex, f as seal, l as packEnvelope, n as FLAG_CONFIRM, o as deriveAuthToken, p as unpackEnvelope, s as deriveDirKeys } from "./e2e-z1xpwSAg.js";
2
- import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-DLaJfTcB.js";
2
+ import { n as isWebrtcSpec, r as parseWebrtcLink, t as SIGNAL_SUBPROTOCOL } from "./webrtcLink-CnQf9pmu.js";
3
3
  import { RTCPeerConnection } from "node-datachannel/polyfill";
4
4
 
5
5
  //#region ts/signalingHint.ts
@@ -297,4 +297,4 @@ async function startWebrtcBridge(link) {
297
297
 
298
298
  //#endregion
299
299
  export { startWebrtcBridge };
300
- //# sourceMappingURL=webrtcRemote-L5NxhLLR.js.map
300
+ //# sourceMappingURL=webrtcRemote-yvMYsij3.js.map
@@ -28,7 +28,7 @@ function parseFlags(args, known) {
28
28
  }
29
29
  /** Resolve the local daemon base URL + token (flags override discovery). */
30
30
  async function daemonTarget(flags) {
31
- const { resolveDaemonHttpBase, loadTokenReadOnly } = await import("./serve-B2nisui8.js");
31
+ const { resolveDaemonHttpBase, loadTokenReadOnly } = await import("./serve-CJDQq_Jp.js");
32
32
  const base = typeof flags.base === "string" ? flags.base : await resolveDaemonHttpBase();
33
33
  if (!base) throw new Error("no running ay serve daemon found — start `ay serve` or pass --base <url> (e.g. http://127.0.0.1:PORT)");
34
34
  const token = typeof flags.token === "string" ? flags.token : await loadTokenReadOnly() ?? "";
@@ -186,7 +186,7 @@ async function cmdMint(args) {
186
186
  const bad = caps.filter((c) => !KNOWN_CAPS.has(c));
187
187
  if (bad.length) throw new Error(`unknown cap(s): ${bad.join(", ")} (valid: ${[...KNOWN_CAPS].join(", ")})`);
188
188
  const ttlSec = parseTtlSec(typeof flags.ttl === "string" ? flags.ttl : "15m");
189
- const { mintScopedTermToken } = await import("./serve-B2nisui8.js");
189
+ const { mintScopedTermToken } = await import("./serve-CJDQq_Jp.js");
190
190
  const r = await mintScopedTermToken(target, {
191
191
  ttlSec,
192
192
  caps
@@ -202,4 +202,4 @@ async function cmdMint(args) {
202
202
 
203
203
  //#endregion
204
204
  export { cmdMint, cmdWidget };
205
- //# sourceMappingURL=widget-OvkqW2Z6.js.map
205
+ //# sourceMappingURL=widget-DhBSNUey.js.map
package/dist/widgets.js CHANGED
@@ -2,7 +2,7 @@ import "./e2e-z1xpwSAg.js";
2
2
  import "./channels-C3w3eAES.js";
3
3
  import "./peer-BZyUyCt3.js";
4
4
  import { t as AyChannel } from "./browser-XQnbPssj.js";
5
- import { t as AyTerminal } from "./browser-oqC3Ln2a.js";
5
+ import { t as AyTerminal } from "./browser-wkzxw6lx.js";
6
6
  import { AyWidget } from "./widget.js";
7
7
 
8
8
  export { AyChannel, AyTerminal, AyWidget };
@@ -1,4 +1,4 @@
1
- import { F as listRecords } from "./subcommands-D6LBtVPM.js";
1
+ import { F as listRecords } from "./subcommands-yWgVRM2r.js";
2
2
  import { a as getProvisionRoot } from "./workspaceConfig-DTvPtr5j.js";
3
3
  import { existsSync } from "fs";
4
4
  import { lstat, opendir } from "fs/promises";
@@ -424,4 +424,4 @@ async function cmdWs(args) {
424
424
 
425
425
  //#endregion
426
426
  export { isPathInside as a, walkWorkspaces as c, importProvisionModule as i, workspaceStatus as l, cmdWs as n, loadProvision as o, collectWorkspaces as r, resolveOperand as s, WS_JSON_SCHEMA as t };
427
- //# sourceMappingURL=ws-BeMouYAU.js.map
427
+ //# sourceMappingURL=ws-CMX0pkti.js.map
@@ -1,14 +1,14 @@
1
1
  import "./logger-CDIsZ-Pp.js";
2
2
  import "./JsonlStore-BqF7AQ6E.js";
3
- import "./globalPidIndex-DoaKEUkO.js";
4
- import "./pidStore-B73xnAeT.js";
5
- import "./messageLog-CVRSTLza.js";
3
+ import "./globalPidIndex-CRH9L9tn.js";
4
+ import "./pidStore-DXWqzTSz.js";
5
+ import "./messageLog-Brx3w6zT.js";
6
6
  import "./e2e-z1xpwSAg.js";
7
7
  import "./configShared-DKTgiz7K.js";
8
- import "./subcommands-D6LBtVPM.js";
8
+ import "./subcommands-yWgVRM2r.js";
9
9
  import "./notifyStore-2SWKGwZI.js";
10
- import "./webrtcLink-DLaJfTcB.js";
11
- import "./remotes-DTXPW8U1.js";
12
- import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-BeMouYAU.js";
10
+ import "./webrtcLink-CnQf9pmu.js";
11
+ import "./remotes-tmKwrr20.js";
12
+ import { a as isPathInside, c as walkWorkspaces, i as importProvisionModule, l as workspaceStatus, n as cmdWs, o as loadProvision, r as collectWorkspaces, s as resolveOperand, t as WS_JSON_SCHEMA } from "./ws-CMX0pkti.js";
13
13
 
14
14
  export { WS_JSON_SCHEMA, cmdWs, collectWorkspaces, isPathInside, loadProvision, walkWorkspaces, workspaceStatus };
@@ -619,13 +619,33 @@ export function layeredRows(entries) {
619
619
  // parent — which is what makes single-node layers vanish.
620
620
  const roomNodes = [];
621
621
  for (const [rid, re] of groupBy(entries, (e) => e._room || "")) {
622
- const peerGroups = groupBy(re, (e) => e._host || "");
622
+ // Group by SOURCE, not by the device label. A label is not an identity: two
623
+ // machines routinely report the same `user@host` — cloned images and
624
+ // container fleets are the normal provisioning case, and several daemons on
625
+ // one box report it too. Grouping by `_host` merged those into one peer
626
+ // group, so their agents landed in a single PID forest and could be shown
627
+ // parented to each other, and the layer then looked single-membered and was
628
+ // hidden entirely — two machines rendered as unlabelled sibling rows with
629
+ // nothing saying which box was which. `_src` is one machine by construction
630
+ // (`<room>/<peerId>` for a codehost peer, the room id for an ay-share room).
631
+ const peerGroups = groupBy(re, (e) => e._src || e._host || "");
623
632
  const multiPeer = peerGroups.length > 1;
633
+ // Labels only have to be unique for READING; identity is `_src` above. When
634
+ // two sources really do share a label, suffix a short slice of the source so
635
+ // the operator can still tell the rows apart before sending a command to one.
636
+ const labelCounts = new Map();
637
+ for (const [, pe] of peerGroups) {
638
+ const l = pe[0]?._host || "";
639
+ if (l) labelCounts.set(l, (labelCounts.get(l) || 0) + 1);
640
+ }
624
641
  const underRoom = [];
625
- for (const [host, pe] of peerGroups) {
642
+ for (const [src, pe] of peerGroups) {
626
643
  const agentNodes = agentForestNodes(pe).map(toAgentNode);
627
- if (multiPeer && host)
628
- underRoom.push({ kind: "peer", label: host, room: rid, host, children: agentNodes });
644
+ const host = pe[0]?._host || "";
645
+ const label =
646
+ host && labelCounts.get(host) > 1 ? `${host} (${String(src).slice(-6)})` : host;
647
+ if (multiPeer && label)
648
+ underRoom.push({ kind: "peer", label, room: rid, host, src, children: agentNodes });
629
649
  else underRoom.push(...agentNodes); // single/unlabelled peer hidden
630
650
  }
631
651
  if (multiRoom && rid) roomNodes.push({ kind: "room", label: rid, children: underRoom });
@@ -646,6 +666,9 @@ export function layeredRows(entries) {
646
666
  entry: node.entry,
647
667
  room: node.room, // peer headers: (room,host) keys the connection-type cache
648
668
  host: node.host,
669
+ // The peer's source id. `host` can repeat across machines, so anything
670
+ // keying per-machine state off a header must use this, not (room,host).
671
+ src: node.src,
649
672
  parentEntry: node.kind === "agent" ? parentAgent : null,
650
673
  branch: railPrefix(ancestorsLast),
651
674
  depth: ancestorsLast.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-yes",
3
- "version": "1.290.7",
3
+ "version": "1.291.0",
4
4
  "description": "A wrapper tool that automates interactions with various AI CLI tools by automatically handling common prompts and responses.",
5
5
  "keywords": [
6
6
  "ai",
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { envelopeAttribution } from "./subcommands.ts";
3
+ import { senderLabel, type MessageRecord } from "./messageLog.ts";
4
+
5
+ const rec = (over: Partial<MessageRecord>): MessageRecord => ({
6
+ at: 1,
7
+ from: { pid: 20298, cli: "claude", cwd: "/x/acme/tree/dev", agent_id: "lane-dev" },
8
+ to: { pid: 2, cli: "claude", cwd: "/x/acme/tree/qa", agent_id: "lane-qa" },
9
+ body: "hi",
10
+ wrapped: true,
11
+ ...over,
12
+ });
13
+
14
+ describe("a different tree claiming a lane is the contradiction", () => {
15
+ // Measured on real traffic before this change: the rule keyed on "is some
16
+ // OTHER registered lane my ancestor", and it ran inverted — LOUD on a lane's
17
+ // own nested processes (same tree, benign) and SILENT while another worktree
18
+ // sent two documents under this lane's name. The working directory is what
19
+ // actually separates them.
20
+ it("is LOUD in the envelope, because the receiving model reads only that", () => {
21
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
22
+ });
23
+
24
+ it("stays quiet for a lane's own detached process in its own tree", () => {
25
+ expect(envelopeAttribution("env-unverified")).toBe("");
26
+ });
27
+
28
+ it("keeps the honest path silent", () => {
29
+ expect(envelopeAttribution("env")).toBe("");
30
+ });
31
+
32
+ it("renders the distinction in a listing, where a human reads deliberately", () => {
33
+ expect(senderLabel(rec({ from_via: "env-uncorroborated" }))).toContain("UNCORROBORATED");
34
+ expect(senderLabel(rec({ from_via: "env-unverified" }))).toContain("unverified");
35
+ expect(senderLabel(rec({ from_via: "env" }))).toBe("claude #20298");
36
+ });
37
+
38
+ it("still records what was measured about the real sender", () => {
39
+ // The audit that found the misattribution used exactly this: `from` said
40
+ // tree/dev, `sender_observed` said a process in tree/billings. Without the
41
+ // observation there is nothing to compare the claim against.
42
+ const r = rec({
43
+ from_via: "env-uncorroborated",
44
+ sender_observed: { user: "alice", host: "box", cwd: "/x/acme/tree/billings", pid: 47240 },
45
+ });
46
+ expect(r.sender_observed!.cwd).not.toBe(r.from!.cwd);
47
+ expect(senderLabel(r)).toContain("UNCORROBORATED");
48
+ });
49
+ });
50
+
51
+ describe("a sibling worktree of the same repo is the same lane", () => {
52
+ // Predicted by a lane before it fired, from its own fleet layout: lanes work
53
+ // in LINKED WORKTREES that are sibling directories, not children —
54
+ // ~/ws/org/_wt/feature-x alongside ~/ws/org/repo/tree/dev. A bare `ay send`
55
+ // from a shell there inherits AGENT_YES_PID honestly, and a path-containment
56
+ // check alone would accuse it.
57
+ //
58
+ // git answers it exactly: linked worktrees of one repository share a git
59
+ // common dir; a separate clone has its own. Measured on the reporting fleet —
60
+ // the sibling worktree resolved to the lane's own .git, and the worktree that
61
+ // had actually been misattributing resolved to a different one. A "same
62
+ // parent directory" heuristic would NOT separate them: both live under the
63
+ // same ancestor.
64
+ it("keeps the honest and the malign case distinguishable", () => {
65
+ // The property that matters, stated as the invariant rather than mocked:
66
+ // sharing a repo is what makes a foreign directory the same lane, and NOT
67
+ // sharing one is what makes it a different lane.
68
+ expect(envelopeAttribution("env-unverified")).toBe("");
69
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
70
+ });
71
+
72
+ it("renders the two outcomes differently in a listing", () => {
73
+ expect(senderLabel(rec({ from_via: "env-unverified" }))).toContain("unverified");
74
+ expect(senderLabel(rec({ from_via: "env-uncorroborated" }))).toContain("UNCORROBORATED");
75
+ });
76
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { envelopeAttribution } from "./subcommands.ts";
3
+ import { senderLabel, type MessageRecord } from "./messageLog.ts";
4
+
5
+ const rec = (over: Partial<MessageRecord>): MessageRecord => ({
6
+ at: 1,
7
+ from: { pid: 20298, cli: "claude", cwd: "/x/acme/tree/dev", agent_id: "lane-dev" },
8
+ to: { pid: 2, cli: "claude", cwd: "/x/acme/tree/qa", agent_id: "lane-qa" },
9
+ body: "hi",
10
+ wrapped: true,
11
+ ...over,
12
+ });
13
+
14
+ describe("a different tree claiming a lane is the contradiction", () => {
15
+ // Measured on real traffic before this change: the rule keyed on "is some
16
+ // OTHER registered lane my ancestor", and it ran inverted — LOUD on a lane's
17
+ // own nested processes (same tree, benign) and SILENT while another worktree
18
+ // sent two documents under this lane's name. The working directory is what
19
+ // actually separates them.
20
+ it("is LOUD in the envelope, because the receiving model reads only that", () => {
21
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
22
+ });
23
+
24
+ it("stays quiet for a lane's own detached process in its own tree", () => {
25
+ expect(envelopeAttribution("env-unverified")).toBe("");
26
+ });
27
+
28
+ it("keeps the honest path silent", () => {
29
+ expect(envelopeAttribution("env")).toBe("");
30
+ });
31
+
32
+ it("renders the distinction in a listing, where a human reads deliberately", () => {
33
+ expect(senderLabel(rec({ from_via: "env-uncorroborated" }))).toContain("UNCORROBORATED");
34
+ expect(senderLabel(rec({ from_via: "env-unverified" }))).toContain("unverified");
35
+ expect(senderLabel(rec({ from_via: "env" }))).toBe("claude #20298");
36
+ });
37
+
38
+ it("still records what was measured about the real sender", () => {
39
+ // The audit that found the misattribution used exactly this: `from` said
40
+ // tree/dev, `sender_observed` said a process in tree/billings. Without the
41
+ // observation there is nothing to compare the claim against.
42
+ const r = rec({
43
+ from_via: "env-uncorroborated",
44
+ sender_observed: { user: "alice", host: "box", cwd: "/x/acme/tree/billings", pid: 47240 },
45
+ });
46
+ expect(r.sender_observed!.cwd).not.toBe(r.from!.cwd);
47
+ expect(senderLabel(r)).toContain("UNCORROBORATED");
48
+ });
49
+ });
50
+
51
+ describe("a sibling worktree of the same repo is the same lane", () => {
52
+ // Predicted by a lane before it fired, from its own fleet layout: lanes work
53
+ // in LINKED WORKTREES that are sibling directories, not children —
54
+ // ~/ws/org/_wt/feature-x alongside ~/ws/org/repo/tree/dev. A bare `ay send`
55
+ // from a shell there inherits AGENT_YES_PID honestly, and a path-containment
56
+ // check alone would accuse it.
57
+ //
58
+ // git answers it exactly: linked worktrees of one repository share a git
59
+ // common dir; a separate clone has its own. Measured on the reporting fleet —
60
+ // the sibling worktree resolved to the lane's own .git, and the worktree that
61
+ // had actually been misattributing resolved to a different one. A "same
62
+ // parent directory" heuristic would NOT separate them: both live under the
63
+ // same ancestor.
64
+ it("keeps the honest and the malign case distinguishable", () => {
65
+ // The property that matters, stated as the invariant rather than mocked:
66
+ // sharing a repo is what makes a foreign directory the same lane, and NOT
67
+ // sharing one is what makes it a different lane.
68
+ expect(envelopeAttribution("env-unverified")).toBe("");
69
+ expect(envelopeAttribution("env-uncorroborated")).toContain("UNCORROBORATED");
70
+ });
71
+
72
+ it("renders the two outcomes differently in a listing", () => {
73
+ expect(senderLabel(rec({ from_via: "env-unverified" }))).toContain("unverified");
74
+ expect(senderLabel(rec({ from_via: "env-uncorroborated" }))).toContain("UNCORROBORATED");
75
+ });
76
+ });
@@ -389,6 +389,159 @@ describe("globalPidIndex", () => {
389
389
  });
390
390
  });
391
391
 
392
+ describe("sweepOrphanLogs", () => {
393
+ const DAY = 24 * 60 * 60 * 1000;
394
+
395
+ // The sweep always includes the caller's own cwd. Pin it inside the temp
396
+ // home so the suite can never reach the real repo's `.agent-yes/`.
397
+ beforeEach(() => {
398
+ vi.spyOn(process, "cwd").mockReturnValue(testHome);
399
+ });
400
+ afterEach(() => {
401
+ vi.restoreAllMocks();
402
+ });
403
+
404
+ /** Backdate a file so it falls outside the retention window. */
405
+ async function backdate(file: string, ageMs: number) {
406
+ const { utimes } = await import("fs/promises");
407
+ const when = new Date(Date.now() - ageMs);
408
+ await utimes(file, when, when);
409
+ }
410
+
411
+ it("reclaims a dead pid's logs that no index record points at", async () => {
412
+ const mod = await loadModule();
413
+ const { mkdir, writeFile } = await import("fs/promises");
414
+ const { existsSync } = await import("fs");
415
+
416
+ // A project dir the index reaches only via a *sibling* record — the
417
+ // orphan's own record was already compacted away.
418
+ const projectCwd = path.join(testHome, "proj");
419
+ const dir = path.join(projectCwd, ".agent-yes");
420
+ await mkdir(dir, { recursive: true });
421
+
422
+ const orphanRaw = path.join(dir, "999999.raw.log");
423
+ const orphanDebug = path.join(dir, "999999.debug.log");
424
+ for (const f of [orphanRaw, orphanDebug]) {
425
+ await writeFile(f, "0123456789"); // 10 bytes each
426
+ await backdate(f, 30 * DAY);
427
+ }
428
+
429
+ // The only record in the index belongs to a different, live session.
430
+ const liveRaw = path.join(dir, `${process.pid}.raw.log`);
431
+ await writeFile(liveRaw, "x");
432
+ await backdate(liveRaw, 30 * DAY);
433
+ await mod.appendGlobalPid({
434
+ pid: process.pid,
435
+ cli: "claude",
436
+ prompt: null,
437
+ cwd: projectCwd,
438
+ log_file: liveRaw,
439
+ status: "active",
440
+ exit_code: null,
441
+ exit_reason: null,
442
+ started_at: Date.now(),
443
+ });
444
+
445
+ const res = await mod.sweepOrphanLogs();
446
+
447
+ expect(res.removed.sort()).toEqual([orphanDebug, orphanRaw].sort());
448
+ expect(res.freedBytes).toBe(20);
449
+ expect(existsSync(orphanRaw)).toBe(false);
450
+ expect(existsSync(orphanDebug)).toBe(false);
451
+ expect(existsSync(liveRaw)).toBe(true); // pid still running
452
+ });
453
+
454
+ it("keeps logs inside the retention window and non-log files", async () => {
455
+ const mod = await loadModule();
456
+ const { mkdir, writeFile } = await import("fs/promises");
457
+ const { existsSync } = await import("fs");
458
+
459
+ const projectCwd = path.join(testHome, "proj");
460
+ const dir = path.join(projectCwd, ".agent-yes");
461
+ await mkdir(dir, { recursive: true });
462
+
463
+ const recent = path.join(dir, "999998.raw.log"); // dead pid, but fresh
464
+ await writeFile(recent, "x");
465
+
466
+ // Runtime state that shares the dir and must never be swept.
467
+ const inbox = path.join(dir, "inbox.jsonl");
468
+ const sqlite = path.join(dir, "pid.sqlite");
469
+ const records = path.join(dir, "pid-records.jsonl");
470
+ for (const f of [inbox, sqlite, records]) {
471
+ await writeFile(f, "x");
472
+ await backdate(f, 30 * DAY);
473
+ }
474
+
475
+ await mod.appendGlobalPid({
476
+ pid: 999998,
477
+ cli: "claude",
478
+ prompt: null,
479
+ cwd: projectCwd,
480
+ log_file: recent,
481
+ status: "exited",
482
+ exit_code: 0,
483
+ exit_reason: null,
484
+ started_at: Date.now(),
485
+ });
486
+
487
+ const res = await mod.sweepOrphanLogs();
488
+
489
+ expect(res.removed).toEqual([]);
490
+ for (const f of [recent, inbox, sqlite, records]) expect(existsSync(f)).toBe(true);
491
+ });
492
+
493
+ it("does not throw when the index is empty and the cwd has no .agent-yes", async () => {
494
+ const mod = await loadModule();
495
+ const res = await mod.sweepOrphanLogs();
496
+ expect(res.removed).toEqual([]);
497
+ expect(res.freedBytes).toBe(0);
498
+ });
499
+ });
500
+
501
+ describe("gcLogs", () => {
502
+ beforeEach(() => {
503
+ vi.spyOn(process, "cwd").mockReturnValue(testHome);
504
+ });
505
+ afterEach(() => {
506
+ vi.restoreAllMocks();
507
+ });
508
+
509
+ it("counts a file once when both passes would claim it", async () => {
510
+ const mod = await loadModule();
511
+ const { mkdir, writeFile, utimes } = await import("fs/promises");
512
+ const { existsSync } = await import("fs");
513
+
514
+ const projectCwd = path.join(testHome, "proj");
515
+ const dir = path.join(projectCwd, ".agent-yes");
516
+ await mkdir(dir, { recursive: true });
517
+
518
+ // Dead + old in BOTH senses: the index still has its record (so the
519
+ // retention pass claims it) and its mtime is stale (so the sweep would
520
+ // too). It must be reported exactly once.
521
+ const raw = path.join(dir, "999999.raw.log");
522
+ await writeFile(raw, "01234"); // 5 bytes
523
+ const old = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
524
+ await utimes(raw, old, old);
525
+ await mod.appendGlobalPid({
526
+ pid: 999999,
527
+ cli: "claude",
528
+ prompt: null,
529
+ cwd: projectCwd,
530
+ log_file: raw,
531
+ status: "exited",
532
+ exit_code: 0,
533
+ exit_reason: null,
534
+ started_at: old.getTime(),
535
+ });
536
+
537
+ const res = await mod.gcLogs();
538
+
539
+ expect(res.removed).toEqual([raw]);
540
+ expect(res.freedBytes).toBe(5);
541
+ expect(existsSync(raw)).toBe(false);
542
+ });
543
+ });
544
+
392
545
  it("skips corrupt lines without throwing", async () => {
393
546
  const mod = await loadModule();
394
547
  await mod.appendGlobalPid({