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,130 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ REMOTE_BACKOFF_BASE_MS,
4
+ REMOTE_BACKOFF_MAX_MS,
5
+ noteRemoteResult,
6
+ pruneRemoteHealth,
7
+ remoteBackoffMs,
8
+ shouldSkipRemote,
9
+ } from "./remoteHealth.ts";
10
+
11
+ describe("remoteHealth.remoteBackoffMs", () => {
12
+ it("doubles from the base and then holds at the cap", () => {
13
+ expect(remoteBackoffMs(1)).toBe(REMOTE_BACKOFF_BASE_MS);
14
+ expect(remoteBackoffMs(2)).toBe(REMOTE_BACKOFF_BASE_MS * 2);
15
+ expect(remoteBackoffMs(3)).toBe(REMOTE_BACKOFF_BASE_MS * 4);
16
+ expect(remoteBackoffMs(99)).toBe(REMOTE_BACKOFF_MAX_MS);
17
+ });
18
+
19
+ it("is zero for a host that has not failed — and this GUARDS the clause below", () => {
20
+ // A streak of 0 must never produce a wait: that is the difference between
21
+ // "healthy" and "failed once", and getting it wrong skips a live host.
22
+ expect(remoteBackoffMs(0)).toBe(0);
23
+ expect(remoteBackoffMs(-1)).toBe(0);
24
+
25
+ // This is also the tripwire for `shouldSkipRemote`'s `streak <= 0` clause.
26
+ // That clause is UNREACHABLE while this holds, which is why no test can
27
+ // redden it directly — but change the curve so a non-positive streak yields
28
+ // a non-zero wait and the clause goes LIVE, with nothing behind it, at the
29
+ // exact moment someone is editing the thing it guards. So the invariant is
30
+ // pinned HERE, where it is reachable and can fail loudly, instead.
31
+ });
32
+
33
+ it("does not overflow on an absurd streak", () => {
34
+ // 2 ** 5000 is Infinity; the shift clamp is what keeps this finite.
35
+ expect(Number.isFinite(remoteBackoffMs(5000))).toBe(true);
36
+ });
37
+ });
38
+
39
+ describe("remoteHealth.shouldSkipRemote", () => {
40
+ const NOW = 1_000_000_000;
41
+
42
+ it("never skips a host with no record — the control that matters", () => {
43
+ // Wrongly skipping makes an agent silently vanish from `ay ls`; wrongly
44
+ // probing costs one slow list. Those are not symmetric, so the tie goes to
45
+ // probing, and an unknown host is always probed.
46
+ expect(shouldSkipRemote(undefined, NOW)).toBe(false);
47
+ // A cleared host carrying a stale timestamp — what `noteRemoteResult`
48
+ // produces on success — must also never be skipped. This asserts the
49
+ // BEHAVIOUR; it does not isolate the `streak <= 0` clause, which is
50
+ // redundant with `remoteBackoffMs(0) === 0` and cannot be reddened on its
51
+ // own. Said plainly rather than left looking like coverage it is not.
52
+ expect(shouldSkipRemote({ streak: 0, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
53
+ expect(shouldSkipRemote({ streak: -3, lastFailedAt: NOW - 1 }, NOW)).toBe(false);
54
+ });
55
+
56
+ it("skips inside the backoff window and probes again once it elapses", () => {
57
+ const h = { streak: 1, lastFailedAt: NOW };
58
+ expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS - 1)).toBe(true);
59
+ expect(shouldSkipRemote(h, NOW + REMOTE_BACKOFF_BASE_MS)).toBe(false);
60
+ });
61
+
62
+ it("probes rather than guesses when the clock has moved backwards", () => {
63
+ // A backwards clock makes `elapsed` negative, which would otherwise read as
64
+ // "still inside the window" and skip the host until the clock caught up.
65
+ expect(shouldSkipRemote({ streak: 3, lastFailedAt: NOW }, NOW - 60_000)).toBe(false);
66
+ });
67
+ });
68
+
69
+ describe("remoteHealth.readRemoteHealth validation", () => {
70
+ it("ignores an entry whose shape it cannot trust", async () => {
71
+ // The file is derived data in a writable path. A valid-JSON record with a
72
+ // plausible-looking streak must not be able to hide a live agent, so
73
+ // anything that is not two finite numbers is dropped rather than trusted.
74
+ const { mkdtemp, writeFile } = await import("fs/promises");
75
+ const { tmpdir } = await import("os");
76
+ const path = (await import("path")).default;
77
+ const dir = await mkdtemp(path.join(tmpdir(), "ay-health-"));
78
+ const saved = process.env.AGENT_YES_HOME;
79
+ process.env.AGENT_YES_HOME = dir;
80
+ try {
81
+ await writeFile(
82
+ path.join(dir, "remote-health.json"),
83
+ JSON.stringify({
84
+ good: { streak: 2, lastFailedAt: 123 },
85
+ missingField: { streak: 2 },
86
+ wrongType: { streak: "9", lastFailedAt: 1 },
87
+ notFinite: { streak: Number.POSITIVE_INFINITY, lastFailedAt: 1 },
88
+ notAnObject: 7,
89
+ }),
90
+ );
91
+ const { readRemoteHealth } = await import("./remoteHealth.ts");
92
+ expect(await readRemoteHealth()).toEqual({ good: { streak: 2, lastFailedAt: 123 } });
93
+ } finally {
94
+ if (saved === undefined) delete process.env.AGENT_YES_HOME;
95
+ else process.env.AGENT_YES_HOME = saved;
96
+ }
97
+ });
98
+ });
99
+
100
+ describe("remoteHealth.pruneRemoteHealth", () => {
101
+ it("drops aliases no longer configured, so the file cannot grow forever", () => {
102
+ const h = {
103
+ live: { streak: 1, lastFailedAt: 5 },
104
+ removed: { streak: 9, lastFailedAt: 5 },
105
+ };
106
+ expect(pruneRemoteHealth(h, ["live"])).toEqual({ live: { streak: 1, lastFailedAt: 5 } });
107
+ expect(pruneRemoteHealth(h, [])).toEqual({});
108
+ });
109
+ });
110
+
111
+ describe("remoteHealth.noteRemoteResult", () => {
112
+ const NOW = 1_000_000_000;
113
+
114
+ it("clears the streak completely on success", () => {
115
+ // A host that comes back must return to full speed immediately, not decay
116
+ // through the backoff it earned while it was down.
117
+ expect(noteRemoteResult({ streak: 9, lastFailedAt: NOW }, true, NOW + 1)).toEqual({
118
+ streak: 0,
119
+ lastFailedAt: 0,
120
+ });
121
+ });
122
+
123
+ it("extends the streak on failure, from no record and from an existing one", () => {
124
+ expect(noteRemoteResult(undefined, false, NOW)).toEqual({ streak: 1, lastFailedAt: NOW });
125
+ expect(noteRemoteResult({ streak: 2, lastFailedAt: 1 }, false, NOW)).toEqual({
126
+ streak: 3,
127
+ lastFailedAt: NOW,
128
+ });
129
+ });
130
+ });
@@ -0,0 +1,149 @@
1
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
2
+ import { homedir } from "os";
3
+ import path from "path";
4
+
5
+ /**
6
+ * Backoff for remotes that are not answering.
7
+ *
8
+ * `ay ls` gathers every configured remote on every invocation. A host that has
9
+ * gone away costs a full `CONNECT_TIMEOUT_MS` (25s for a WebRTC spec) and
10
+ * returns nothing, and `Promise.allSettled` waits for all of them — so two dead
11
+ * entries in `remotes.yaml` made every `ay ls` on this fleet take 27s instead of
12
+ * 5s. Measured, then confirmed by removing only those two from a COPY of the
13
+ * config: 27.40s → 5.49s.
14
+ *
15
+ * Deleting the remote is the wrong remedy: those machines can come back, and a
16
+ * config someone must REMEMBER to re-add is a worse failure than a slow list.
17
+ * So the entry stays, and we stop paying for it every time.
18
+ *
19
+ * WHY EXPONENTIAL AND NOT φ: the operator's φ-backoff rule (golden ratio, no
20
+ * 2x) is a symval/symval convention — its AGENTS.md, its helpers. agent-yes is a
21
+ * general tool in snomiao/ and that rule does not reach it. This repo already
22
+ * has one backoff shape, `ts/autoRetry.ts` (base × 2ⁿ, capped, mirrored in
23
+ * rs/src/context.rs), and one repo with two backoff shapes is its own defect.
24
+ * So this mirrors autoRetry deliberately rather than importing a foreign
25
+ * convention or inventing a third.
26
+ */
27
+
28
+ /** First skip window after a host fails. Short: a blip should barely register. */
29
+ export const REMOTE_BACKOFF_BASE_MS = 60_000;
30
+ /** Cap, mirroring autoRetry's shape: 1m, 2m, 4m, … 32m, then hold. */
31
+ export const REMOTE_BACKOFF_MAX_MS = 32 * 60_000;
32
+
33
+ /** One host's consecutive-failure state. Empty for a host that has never failed. */
34
+ export interface RemoteHealth {
35
+ /** Consecutive failures. Reset to 0 by any success. */
36
+ streak: number;
37
+ /** When the most recent failure was recorded. */
38
+ lastFailedAt: number;
39
+ }
40
+
41
+ /**
42
+ * How long to skip a host after `streak` consecutive failures — doubling, then
43
+ * capped, exactly as `autoRetryBackoffMs` does.
44
+ */
45
+ export function remoteBackoffMs(streak: number): number {
46
+ if (streak <= 0) return 0;
47
+ const shift = Math.min(streak - 1, 20); // guard: 2**n on an absurd streak
48
+ return Math.min(REMOTE_BACKOFF_BASE_MS * 2 ** shift, REMOTE_BACKOFF_MAX_MS);
49
+ }
50
+
51
+ /**
52
+ * Whether a host should be SKIPPED right now.
53
+ *
54
+ * Fails OPEN in every ambiguous case — no record, a zero/negative streak, a
55
+ * clock that has gone backwards. The cost of wrongly skipping is an agent that
56
+ * silently vanishes from `ay ls`; the cost of wrongly probing is one slow list.
57
+ * Those are not symmetric, so the tie goes to probing.
58
+ */
59
+ export function shouldSkipRemote(health: RemoteHealth | undefined, now: number): boolean {
60
+ // The `streak <= 0` half is REDUNDANT today — `remoteBackoffMs` already
61
+ // returns 0 for a non-positive streak, so the comparison below is false
62
+ // anyway. Verified: no input distinguishes this function with and without it,
63
+ // which is why no test covers that clause and none pretends to. Kept because
64
+ // it states the invariant at the point a reader needs it, and it stops a
65
+ // future change to the backoff curve from silently making "healthy" skippable.
66
+ // The tripwire for that lives in the spec, on `remoteBackoffMs(0) === 0` —
67
+ // reachable, so it can actually fail — and says there that it guards this.
68
+ if (!health || health.streak <= 0) return false;
69
+ const elapsed = now - health.lastFailedAt;
70
+ if (elapsed < 0) return false; // clock moved backwards — probe rather than guess
71
+ return elapsed < remoteBackoffMs(health.streak);
72
+ }
73
+
74
+ /** Record an outcome. Success clears the streak entirely; failure extends it. */
75
+ export function noteRemoteResult(
76
+ health: RemoteHealth | undefined,
77
+ ok: boolean,
78
+ now: number,
79
+ ): RemoteHealth {
80
+ if (ok) return { streak: 0, lastFailedAt: 0 };
81
+ return { streak: (health?.streak ?? 0) + 1, lastFailedAt: now };
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Persistence
86
+ // ---------------------------------------------------------------------------
87
+
88
+ /**
89
+ * Health lives beside `remotes.yaml` in the state dir, NOT inside it: that file
90
+ * is the operator's config and this is derived, disposable data. Deleting it
91
+ * costs one slow `ay ls` and nothing else.
92
+ */
93
+ function healthPath(): string {
94
+ const dir = process.env.AGENT_YES_HOME ?? path.join(homedir(), ".agent-yes");
95
+ return path.join(dir, "remote-health.json");
96
+ }
97
+
98
+ /** Read the per-alias health map. Any error yields {} — probe rather than skip. */
99
+ export async function readRemoteHealth(): Promise<Record<string, RemoteHealth>> {
100
+ try {
101
+ const raw = await readFile(healthPath(), "utf-8");
102
+ const parsed = JSON.parse(raw) as unknown;
103
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
104
+ // Validate each entry rather than trusting the file. This is derived data in
105
+ // a writable path: a hand-edited or half-migrated record with a plausible
106
+ // streak would otherwise skip a healthy host, and the whole point of the
107
+ // fail-open design is that no bad input can hide a live agent.
108
+ const out: Record<string, RemoteHealth> = {};
109
+ for (const [alias, v] of Object.entries(parsed as Record<string, unknown>)) {
110
+ if (!v || typeof v !== "object") continue;
111
+ const { streak, lastFailedAt } = v as Partial<RemoteHealth>;
112
+ if (typeof streak !== "number" || !Number.isFinite(streak)) continue;
113
+ if (typeof lastFailedAt !== "number" || !Number.isFinite(lastFailedAt)) continue;
114
+ out[alias] = { streak, lastFailedAt };
115
+ }
116
+ return out;
117
+ } catch {
118
+ return {}; // missing, unreadable, or corrupt — fail open
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Best-effort write. A health file we cannot persist must never break `ay ls`.
124
+ *
125
+ * Written to a temp file and renamed, so a concurrent reader sees either the old
126
+ * file or the new one and never a truncated one. Two `ay ls` processes finishing
127
+ * together still last-writer-wins — that is acceptable here because the loser's
128
+ * data is one probe's worth of a self-correcting counter, and because reading a
129
+ * torn file already fails open to "probe".
130
+ */
131
+ export async function writeRemoteHealth(health: Record<string, RemoteHealth>): Promise<void> {
132
+ const dest = healthPath();
133
+ await mkdir(path.dirname(dest), { recursive: true });
134
+ const tmp = `${dest}.${process.pid}.tmp`;
135
+ await writeFile(tmp, JSON.stringify(health, null, 2));
136
+ await rename(tmp, dest);
137
+ }
138
+
139
+ /**
140
+ * Drop entries for aliases that are no longer configured, so the file cannot
141
+ * grow without bound as remotes come and go. Called with the live alias set.
142
+ */
143
+ export function pruneRemoteHealth(
144
+ health: Record<string, RemoteHealth>,
145
+ aliases: Iterable<string>,
146
+ ): Record<string, RemoteHealth> {
147
+ const live = new Set(aliases);
148
+ return Object.fromEntries(Object.entries(health).filter(([alias]) => live.has(alias)));
149
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { pidOwnershipVerdict } from "./senderAncestry.ts";
3
+
4
+ // The verdict cmdRestart consults before SIGKILL. What matters here is not the
5
+ // arithmetic — that is ageMatchesRegistration's, tested with the send path —
6
+ // but that "cannot establish" stays a THIRD answer. Collapsing it into
7
+ // "reused" would read as caution and act as removal: win32 has no process
8
+ // table reader, so every force-kill there would refuse forever.
9
+ describe("restart must not SIGKILL a recycled pid", () => {
10
+ const now = 1_000_000_000;
11
+ const hourOld = now - 3600_000;
12
+
13
+ it("ours: the process is as old as the row that registered it", () => {
14
+ expect(pidOwnershipVerdict(3600, hourOld, now)).toBe("ours");
15
+ });
16
+
17
+ it("reused: the process is younger than the row, so it cannot be the agent", () => {
18
+ // Shape of the live case: a row surviving a reboot whose pid had been
19
+ // handed to an unrelated system process. Nothing in the code was declining
20
+ // to kill it — EPERM was, and only because the owner differed. A same-user
21
+ // recycled pid would have been killed.
22
+ expect(pidOwnershipVerdict(60, now - 89 * 86400_000, now)).toBe("reused");
23
+ });
24
+
25
+ it("unknown when there is no process table — NOT reused", () => {
26
+ // What `table?.get(pid)?.ageSecs` yields on win32, where readAncestryTable
27
+ // returns null by design, and on a transient ps failure.
28
+ expect(pidOwnershipVerdict(undefined, hourOld, now)).toBe("unknown");
29
+ });
30
+
31
+ it("unknown when the row carries no registration time — NOT reused", () => {
32
+ expect(pidOwnershipVerdict(3600, undefined, now)).toBe("unknown");
33
+ });
34
+
35
+ it("does not read ordinary registration lag as reuse", () => {
36
+ // A process is a moment older than the row recording it; if that read as
37
+ // reuse, restart would stop force-killing healthy agents.
38
+ expect(pidOwnershipVerdict(3595, hourOld, now)).toBe("ours");
39
+ });
40
+ });
@@ -0,0 +1,40 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pidOwnershipVerdict } from "./senderAncestry.ts";
3
+
4
+ // The verdict cmdRestart consults before SIGKILL. What matters here is not the
5
+ // arithmetic — that is ageMatchesRegistration's, tested with the send path —
6
+ // but that "cannot establish" stays a THIRD answer. Collapsing it into
7
+ // "reused" would read as caution and act as removal: win32 has no process
8
+ // table reader, so every force-kill there would refuse forever.
9
+ describe("restart must not SIGKILL a recycled pid", () => {
10
+ const now = 1_000_000_000;
11
+ const hourOld = now - 3600_000;
12
+
13
+ it("ours: the process is as old as the row that registered it", () => {
14
+ expect(pidOwnershipVerdict(3600, hourOld, now)).toBe("ours");
15
+ });
16
+
17
+ it("reused: the process is younger than the row, so it cannot be the agent", () => {
18
+ // Shape of the live case: a row surviving a reboot whose pid had been
19
+ // handed to an unrelated system process. Nothing in the code was declining
20
+ // to kill it — EPERM was, and only because the owner differed. A same-user
21
+ // recycled pid would have been killed.
22
+ expect(pidOwnershipVerdict(60, now - 89 * 86400_000, now)).toBe("reused");
23
+ });
24
+
25
+ it("unknown when there is no process table — NOT reused", () => {
26
+ // What `table?.get(pid)?.ageSecs` yields on win32, where readAncestryTable
27
+ // returns null by design, and on a transient ps failure.
28
+ expect(pidOwnershipVerdict(undefined, hourOld, now)).toBe("unknown");
29
+ });
30
+
31
+ it("unknown when the row carries no registration time — NOT reused", () => {
32
+ expect(pidOwnershipVerdict(3600, undefined, now)).toBe("unknown");
33
+ });
34
+
35
+ it("does not read ordinary registration lag as reuse", () => {
36
+ // A process is a moment older than the row recording it; if that read as
37
+ // reuse, restart would stop force-killing healthy agents.
38
+ expect(pidOwnershipVerdict(3595, hourOld, now)).toBe("ours");
39
+ });
40
+ });
@@ -43,14 +43,28 @@ export type SenderVia =
43
43
  /** No env, but an ancestor process is a registered agent. Not forgeable from
44
44
  * inside a message: a process cannot choose its own ancestors. */
45
45
  | "ancestry"
46
- /** `AGENT_YES_PID` named a registered agent that is NOT among this process's
47
- * ancestors, or ancestry could not be read to check. The attribution is the
48
- * caller's own claim and nothing corroborates it. Any process can export that
49
- * variable, so this is the one attributed value a receiver should not trust
50
- * on its own. Kept attributed rather than discarded: a stale env on an honest
51
- * lane lands here too, and dropping it would be the starvation this exists to
52
- * end. */
46
+ /** The claimed lane is NOT this process's ancestor, and the process is running
47
+ * OUTSIDE that lane's working tree. Another tree is claiming this identity.
48
+ *
49
+ * The discriminator is the working directory, not "is some other registered
50
+ * lane my ancestor" — that earlier rule was measured backwards on real
51
+ * traffic: it fired on a lane's own nested processes (same tree, benign) and
52
+ * stayed silent on two documents a different worktree sent under this lane's
53
+ * name. A lane's own detached helper runs inside the lane's tree; a different
54
+ * lane's process does not. */
53
55
  | "env-uncorroborated"
56
+ /** The claim could not be corroborated, and nothing contradicts it either: the
57
+ * process is running inside the claimed lane's own tree (so it is plausibly
58
+ * that lane's detached helper), or the ancestry could not be read at all.
59
+ *
60
+ * This is NOT evidence of anything. A detached helper, a process re-parented
61
+ * to init, an `ay send` from a background job, or an unreadable process table
62
+ * all land here, and every one of them is honest. Distinguishing it from a
63
+ * real contradiction is the whole point: the first live firing of the loud
64
+ * marker was a legitimate long-lived lane, and a warning that fires on honest
65
+ * traffic is one people learn to skip — which destroys the signal for the
66
+ * case it was built for. Unknown has to stay expressible AS unknown. */
67
+ | "env-unverified"
54
68
  /** Nothing identified the caller; only its observable facts are recorded. */
55
69
  | "observed";
56
70
 
@@ -197,6 +211,30 @@ export async function findAgentAncestor<T>(
197
211
  * Exposed so a caller can do the ancestry walk AND the pid-reuse age check off
198
212
  * a single `ps`, instead of paying for the spawn twice.
199
213
  */
214
+ export type PidOwnership = "ours" | "reused" | "unknown";
215
+
216
+ /**
217
+ * Whether the process holding a pid is still the agent that registered it.
218
+ *
219
+ * Three-way on purpose, and the third value is the point. "Cannot establish"
220
+ * is not "established that it is not ours": win32 has no process table reader
221
+ * here at all, so folding unknown into reused would not guard a destructive
222
+ * action on that platform — it would delete the action from it, permanently,
223
+ * to protect a case that has never occurred. Callers refuse on evidence
224
+ * ("reused") and keep their prior behaviour on "unknown".
225
+ */
226
+ export function pidOwnershipVerdict(
227
+ ageSecs: number | undefined,
228
+ startedAt: number | undefined,
229
+ now = Date.now(),
230
+ toleranceSecs = 60,
231
+ ): PidOwnership {
232
+ if (ageSecs === undefined || startedAt === undefined) return "unknown";
233
+ return ageMatchesRegistration(ageSecs, startedAt, now, toleranceSecs)
234
+ ? "ours"
235
+ : "reused";
236
+ }
237
+
200
238
  export async function readAncestryTable(
201
239
  read: () => Promise<Map<number, ProcRow>> = readProcessTable,
202
240
  ): Promise<Map<number, ProcRow> | null> {
package/ts/serve.ts CHANGED
@@ -77,6 +77,7 @@ import { SUPPORTED_CLIS } from "./SUPPORTED_CLIS.ts";
77
77
  import { getInstalledPackage } from "./versionChecker.ts";
78
78
  import { negotiateSize, sanitizeCap, type SizeCap } from "./sizeNego.ts";
79
79
  import { listStrayServeProcesses } from "./strayServe.ts";
80
+ import { isWebrtcSpec, toWebrtcUrl } from "./webrtcLink.ts";
80
81
  import {
81
82
  getProvisionHook,
82
83
  getProvisionRoot,
@@ -1160,19 +1161,17 @@ async function warnStrayServeProcesses(): Promise<void> {
1160
1161
  );
1161
1162
  }
1162
1163
 
1163
- // An explicit webrtc:// URL passed to --webrtc/--share in the daemon's serve args,
1164
- // or undefined for a bare flag (which mints a persisted room instead). Mirrors how
1165
- // cmdServe resolves argv.webrtc/argv.share, but over the raw arg list install holds
1166
- // (oxmgr splits the command on whitespace → `--webrtc url`; pm2/`=` → `--webrtc=url`).
1164
+ // An explicit room URL passed to --webrtc/--share in the daemon's serve args,
1165
+ // normalized to the internal webrtc:// form — or undefined for a bare flag (which
1166
+ // mints a persisted room instead). Mirrors how cmdServe resolves argv.webrtc/argv.share,
1167
+ // but over the raw arg list install holds (oxmgr splits the command on whitespace →
1168
+ // `--webrtc url`; pm2/`=` → `--webrtc=url`).
1167
1169
  function explicitWebrtcUrl(args: string[]): string | undefined {
1168
1170
  for (let i = 0; i < args.length; i++) {
1169
1171
  const a = args[i]!;
1170
1172
  for (const flag of ["--webrtc", "--share"]) {
1171
- if (a === flag && args[i + 1]?.startsWith("webrtc://")) return args[i + 1];
1172
- if (a.startsWith(`${flag}=`)) {
1173
- const v = a.slice(flag.length + 1);
1174
- if (v.startsWith("webrtc://")) return v;
1175
- }
1173
+ const v = a === flag ? args[i + 1] : a.startsWith(`${flag}=`) ? a.slice(flag.length + 1) : "";
1174
+ if (v && isWebrtcSpec(v)) return toWebrtcUrl(v) ?? undefined;
1176
1175
  }
1177
1176
  }
1178
1177
  return undefined;
@@ -1630,7 +1629,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
1630
1629
  `Modes (default: --http):\n` +
1631
1630
  ` --http HTTP API + web console on --port; no WebRTC\n` +
1632
1631
  ` --webrtc [URL] Share over WebRTC (bare flag mints a room+link on\n` +
1633
- ` agent-yes.com, or pass webrtc://room:token@host).\n` +
1632
+ ` agent-yes.com, or pass a room link to join that room:\n` +
1633
+ ` https://agent-yes.com/room/#room=<id>&s=e1.<secret>).\n` +
1634
1634
  ` Alone it needs NO port — combine with --http for both.\n` +
1635
1635
  ` The minted room persists in ~/.agent-yes/.share-room\n` +
1636
1636
  ` (stable link across restarts; delete the file to rotate).\n` +
@@ -1706,7 +1706,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
1706
1706
  .option("webrtc", {
1707
1707
  type: "string",
1708
1708
  description:
1709
- "Share over WebRTC: bare flag mints a room+link, or pass webrtc://room:token@host. Needs no port unless combined with --http",
1709
+ "Share over WebRTC: bare flag mints a room+link, or pass a https://…/room/#… link to join that room. Needs no port unless combined with --http",
1710
1710
  })
1711
1711
  .option("share", {
1712
1712
  type: "string",
@@ -1749,6 +1749,26 @@ export async function cmdServe(rest: string[]): Promise<number> {
1749
1749
 
1750
1750
  const wantWebrtc = argv.webrtc !== undefined || argv.share !== undefined;
1751
1751
  const wantHttp = argv.http === true || argv.share !== undefined || argv.webrtc === undefined;
1752
+ // Resolve --webrtc/--share's value to the internal webrtc:// form up front, so a
1753
+ // bad link fails before any port is bound or lock taken. A value that LOOKS like
1754
+ // a room link but doesn't parse must fail LOUDLY: the old check gated on the
1755
+ // `webrtc://` scheme alone, so every https://…/room/#… link — the shape actually
1756
+ // printed to operators — fell through to the mint-a-fresh-room path. `--webrtc
1757
+ // <someone else's link>` then served its OWN room while the operator believed it
1758
+ // had joined theirs. Empty string = the bare flag, which mints by design.
1759
+ const webrtcArg = (argv.webrtc ?? argv.share) as string | undefined;
1760
+ let explicitRoomUrl: string | undefined;
1761
+ if (typeof webrtcArg === "string" && webrtcArg !== "") {
1762
+ explicitRoomUrl = (isWebrtcSpec(webrtcArg) && toWebrtcUrl(webrtcArg)) || undefined;
1763
+ if (!explicitRoomUrl) {
1764
+ process.stderr.write(
1765
+ `ay serve: --webrtc: not a room link: ${webrtcArg}\n` +
1766
+ ` want https://agent-yes.com/room/#room=<id>&s=e1.<secret>\n` +
1767
+ ` or a bare --webrtc to mint a room of your own\n`,
1768
+ );
1769
+ return 1;
1770
+ }
1771
+ }
1752
1772
  const fixedPort = typeof argv.port === "number";
1753
1773
  const usePortless = wantHttp && !fixedPort;
1754
1774
  if (argv.local === true && fixedPort) {
@@ -5062,9 +5082,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
5062
5082
  // webrtc:// value joins an explicit one.
5063
5083
  let closeShare: (() => void) | undefined; // closes WebRTC peers on shutdown
5064
5084
  if (wantWebrtc) {
5065
- const webrtcVal = (argv.webrtc ?? argv.share) as string | undefined;
5066
- const explicitUrl =
5067
- typeof webrtcVal === "string" && webrtcVal.startsWith("webrtc://") ? webrtcVal : undefined;
5085
+ // Validated (and normalized to webrtc://) up front, alongside wantWebrtc.
5086
+ const explicitUrl = explicitRoomUrl;
5068
5087
  try {
5069
5088
  const { startShare, loadOrCreateShareRoom } = await import("./share.ts");
5070
5089
  const linkFile = path.join(
@@ -5073,7 +5092,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
5073
5092
  );
5074
5093
  // Announce the link — reused for the initial share and for any auto-rotation
5075
5094
  // (when the signaling server rejects a stale persisted room).
5076
- const announce = async (room: string, link: string, rotated: boolean) => {
5095
+ const announce = async (room: string, link: string, joinLink: string, rotated: boolean) => {
5077
5096
  const lead = rotated
5078
5097
  ? "the room was rejected by signaling (stale generation) — rotated to a fresh link"
5079
5098
  : "shared over WebRTC — open this link (the token is eaten from the URL on open)";
@@ -5081,7 +5100,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
5081
5100
  const persistNote = explicitUrl
5082
5101
  ? "\n"
5083
5102
  : ` (persistent room — same link across restarts; delete ~/.agent-yes/.share-room to rotate)\n\n`;
5084
- process.stdout.write(`${wantHttp ? "\n" : ""}${lead}:\n ${link}\n` + persistNote);
5103
+ process.stdout.write(
5104
+ `${wantHttp ? "\n" : ""}${lead}:\n ${link}\n` +
5105
+ `\nto attach ANOTHER machine to this room, open this there (or paste it into\n` +
5106
+ `\`ay serve --webrtc\`) — one machine per link:\n ${joinLink}\n` +
5107
+ persistNote,
5108
+ );
5085
5109
  // Offer to open the console (default yes) on the FIRST share only —
5086
5110
  // an auto-rotation shouldn't pop a fresh tab from under the operator.
5087
5111
  if (!rotated) {
@@ -5092,7 +5116,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
5092
5116
  // Non-TTY (daemon/journal/CI): the link embeds the room secret S, so never
5093
5117
  // write it to a log stream. Stash it in a 0600 file and point there instead.
5094
5118
  try {
5095
- await writeFile(linkFile, link + "\n", { mode: 0o600 });
5119
+ await writeFile(linkFile, `${link}\n${joinLink}\n`, { mode: 0o600 });
5096
5120
  } catch {
5097
5121
  /* best effort */
5098
5122
  }
@@ -5106,14 +5130,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
5106
5130
  // saved like the serve token), so the link is stable across restarts.
5107
5131
  // Only the persisted path may auto-rotate (onRotate set); an explicit URL
5108
5132
  // is the operator's choice and must not be silently changed.
5109
- const { room, link, close } = await startShare({
5133
+ const { room, link, joinLink, close } = await startShare({
5110
5134
  url: explicitUrl ?? (await loadOrCreateShareRoom()),
5111
5135
  localFetch: apiFetch,
5112
5136
  apiToken: token,
5113
- onRotate: explicitUrl ? undefined : (info) => announce(info.room, info.link, true),
5137
+ onRotate: explicitUrl
5138
+ ? undefined
5139
+ : (info) => announce(info.room, info.link, info.joinLink, true),
5114
5140
  });
5115
5141
  closeShare = close;
5116
- await announce(room, link, false);
5142
+ await announce(room, link, joinLink, false);
5117
5143
  } catch (e) {
5118
5144
  process.stderr.write(`ay serve --webrtc failed: ${(e as Error).message}\n`);
5119
5145
  if (!wantHttp) {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "bun:test";
2
- import { shareLinkFromRoomUrl } from "./share.ts";
2
+ import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
3
+ import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
3
4
  import { MARKER } from "../lab/ui/e2e.js";
4
5
 
5
6
  const S = "a".repeat(64); // a valid 64-hex room secret
@@ -31,6 +32,27 @@ describe("shareLinkFromRoomUrl", () => {
31
32
  });
32
33
 
33
34
  it("rejects a malformed room url", () => {
34
- expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/webrtc:\/\//);
35
+ // The message must name the shape operators actually hold (the /room/ link),
36
+ // not the internal webrtc:// form they are never shown.
37
+ expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/\/room\/#room=/);
38
+ });
39
+ });
40
+
41
+ // The /room/ link is BOTH what an operator opens in a browser and what they
42
+ // paste into `ay serve --webrtc`. So whatever formatRoomLink prints must survive
43
+ // the CLI's own parser — if these two ever drift, the printed link stops working
44
+ // in the tool that printed it, which no other test would catch.
45
+ describe("formatRoomLink \u2194 the CLI parser", () => {
46
+ it("prints a link toWebrtcUrl normalizes back to the same room", () => {
47
+ const link = formatRoomLink("r1a2b3c", S, "s.agent-yes.com");
48
+ expect(link).toBe(`https://agent-yes.com/room/#room=r1a2b3c&s=${MARKER}${S}`);
49
+ expect(toWebrtcUrl(link)).toBe(`webrtc://r1a2b3c:${MARKER}${S}@s.agent-yes.com`);
50
+ expect(parseWebrtcLink(link)).toEqual({ room: "r1a2b3c", s: S, host: "s.agent-yes.com" });
51
+ });
52
+
53
+ it("carries a non-prod signaling host as sig= and round-trips it too", () => {
54
+ const link = formatRoomLink("r1a2b3c", S, "localhost:7778");
55
+ expect(link).toContain("sig=localhost%3A7778");
56
+ expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
35
57
  });
36
58
  });
package/ts/share.spec.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { shareLinkFromRoomUrl } from "./share.ts";
2
+ import { formatRoomLink, shareLinkFromRoomUrl } from "./share.ts";
3
+ import { parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
3
4
  import { MARKER } from "../lab/ui/e2e.js";
4
5
 
5
6
  const S = "a".repeat(64); // a valid 64-hex room secret
@@ -31,6 +32,27 @@ describe("shareLinkFromRoomUrl", () => {
31
32
  });
32
33
 
33
34
  it("rejects a malformed room url", () => {
34
- expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/webrtc:\/\//);
35
+ // The message must name the shape operators actually hold (the /room/ link),
36
+ // not the internal webrtc:// form they are never shown.
37
+ expect(() => shareLinkFromRoomUrl("not-a-webrtc-url")).toThrow(/\/room\/#room=/);
38
+ });
39
+ });
40
+
41
+ // The /room/ link is BOTH what an operator opens in a browser and what they
42
+ // paste into `ay serve --webrtc`. So whatever formatRoomLink prints must survive
43
+ // the CLI's own parser — if these two ever drift, the printed link stops working
44
+ // in the tool that printed it, which no other test would catch.
45
+ describe("formatRoomLink \u2194 the CLI parser", () => {
46
+ it("prints a link toWebrtcUrl normalizes back to the same room", () => {
47
+ const link = formatRoomLink("r1a2b3c", S, "s.agent-yes.com");
48
+ expect(link).toBe(`https://agent-yes.com/room/#room=r1a2b3c&s=${MARKER}${S}`);
49
+ expect(toWebrtcUrl(link)).toBe(`webrtc://r1a2b3c:${MARKER}${S}@s.agent-yes.com`);
50
+ expect(parseWebrtcLink(link)).toEqual({ room: "r1a2b3c", s: S, host: "s.agent-yes.com" });
51
+ });
52
+
53
+ it("carries a non-prod signaling host as sig= and round-trips it too", () => {
54
+ const link = formatRoomLink("r1a2b3c", S, "localhost:7778");
55
+ expect(link).toContain("sig=localhost%3A7778");
56
+ expect(parseWebrtcLink(link)).toMatchObject({ room: "r1a2b3c", host: "localhost:7778" });
35
57
  });
36
58
  });