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.
- package/dist/SUPPORTED_CLIS-BZP767UR.js +12 -0
- package/dist/{SUPPORTED_CLIS-hTRegnm5.js → SUPPORTED_CLIS-TiOdGi1h.js} +2 -2
- package/dist/{agentShare-C_LTEKEb.js → agentShare-D4N8StHO.js} +8 -8
- package/dist/{askCli-DR9vhdcV.js → askCli-DFsWcgpF.js} +3 -3
- package/dist/{browser-oqC3Ln2a.js → browser-wkzxw6lx.js} +3 -2
- package/dist/{callback-DAQ8wCtJ.js → callback-Bc2gnr_f.js} +3 -3
- package/dist/{callback-BEvLUUBR.js → callback-yCpK6Qpg.js} +7 -7
- package/dist/{channels-hOG3-R_3.js → channels-aYwLMbFV.js} +2 -2
- package/dist/cli.js +9 -9
- package/dist/{cmdPs-BAaLB4Pz.js → cmdPs-D5L3Eyh5.js} +7 -7
- package/dist/{cwdConflictWarn-BgXN56Jc.js → cwdConflictWarn-sQUONAye.js} +2 -2
- package/dist/{forkNested-CJ7lQQCe.js → forkNested-xtEeYpBQ.js} +3 -3
- package/dist/{globalPidIndex-DoaKEUkO.js → globalPidIndex-CRH9L9tn.js} +139 -12
- package/dist/globalPidIndex-DC93JhHQ.js +4 -0
- package/dist/index.js +5 -5
- package/dist/{messageLog-CVRSTLza.js → messageLog-Brx3w6zT.js} +2 -1
- package/dist/{notifyDaemon-Dnbe66Fl.js → notifyDaemon-CvUtRnAB.js} +7 -7
- package/dist/{parentLink-fdiuB13i.js → parentLink-BILJ32nA.js} +2 -2
- package/dist/{parentLink-bpDGuQi6.js → parentLink-CfbxykCv.js} +2 -2
- package/dist/{parentPingLoop-DWv-xJoi.js → parentPingLoop-CVOCiQp2.js} +4 -4
- package/dist/pidStore-Bpe8i_oU.js +6 -0
- package/dist/{pidStore-B73xnAeT.js → pidStore-DXWqzTSz.js} +2 -2
- package/dist/{remotes-DTXPW8U1.js → remotes-tmKwrr20.js} +3 -3
- package/dist/{remotes-C696AJzL.js → remotes-wPL0JtBs.js} +2 -2
- package/dist/{rustBinary-CZRPfa4y.js → rustBinary-C_EbyMTk.js} +2 -2
- package/dist/{rustBinary-jO6EN8LR.js → rustBinary-Clbk_K5c.js} +2 -2
- package/dist/{schedule-Hao0WqyJ.js → schedule-Ba_4wNVK.js} +7 -7
- package/dist/{serve-B2nisui8.js → serve-CJDQq_Jp.js} +46 -40
- package/dist/{setup-vCDTqyqR.js → setup-DwLAkbTC.js} +2 -2
- package/dist/share-Bmnd_qT0.js +4 -0
- package/dist/{share-DTlaFn6s.js → share-C9oGvjXT.js} +13 -4
- package/dist/spawnGate-BUEswFgo.js +5 -0
- package/dist/{spawnGate-B26UnzqP.js → spawnGate-YpHopveO.js} +2 -2
- package/dist/{subcommands-BpFdlCY0.js → subcommands-x8Pe7DrF.js} +6 -6
- package/dist/{subcommands-D6LBtVPM.js → subcommands-yWgVRM2r.js} +273 -45
- package/dist/{terminal-iGHEnc7Z.js → terminal-Dv73BTE-.js} +2 -2
- package/dist/terminal.js +1 -1
- package/dist/{todoCli-azqbjBtz.js → todoCli-DSQXAcwA.js} +3 -3
- package/dist/{todoIdentity-NEceTmEe.js → todoIdentity-CAmnkzzP.js} +2 -2
- package/dist/{trayApp-BdNlQCFx.js → trayApp-BRw8f0mT.js} +2 -2
- package/dist/trayApp-BjQ5V3WV.js +5 -0
- package/dist/{ts-oyuaLWWx.js → ts-C8PxhJZM.js} +7 -7
- package/dist/{versionChecker-CMjS88mL.js → versionChecker-CWPGASjD.js} +2 -2
- package/dist/webrtcLink-CnQf9pmu.js +73 -0
- package/dist/{webrtcRemote-L5NxhLLR.js → webrtcRemote-yvMYsij3.js} +2 -2
- package/dist/{widget-OvkqW2Z6.js → widget-DhBSNUey.js} +3 -3
- package/dist/widgets.js +1 -1
- package/dist/{ws-BeMouYAU.js → ws-CMX0pkti.js} +2 -2
- package/dist/{ws-qZy7Giub.js → ws-DZ9YAXlY.js} +7 -7
- package/lab/ui/console-logic.js +27 -4
- package/package.json +1 -1
- package/ts/crossTreeClaim.bun.spec.ts +76 -0
- package/ts/crossTreeClaim.spec.ts +76 -0
- package/ts/globalPidIndex.spec.ts +153 -0
- package/ts/globalPidIndex.ts +151 -10
- package/ts/messageLog.bun.spec.ts +33 -0
- package/ts/messageLog.spec.ts +33 -0
- package/ts/messageLog.ts +3 -0
- package/ts/rawInputDeclared.bun.spec.ts +68 -0
- package/ts/rawInputDeclared.spec.ts +68 -0
- package/ts/remoteHealth.bun.spec.ts +130 -0
- package/ts/remoteHealth.spec.ts +130 -0
- package/ts/remoteHealth.ts +149 -0
- package/ts/restartKillGuard.bun.spec.ts +40 -0
- package/ts/restartKillGuard.spec.ts +40 -0
- package/ts/senderAncestry.ts +45 -7
- package/ts/serve.ts +46 -20
- package/ts/share.bun.spec.ts +24 -2
- package/ts/share.spec.ts +24 -2
- package/ts/share.ts +37 -5
- package/ts/subcommands.ts +239 -19
- package/ts/terminal/browser.ts +5 -1
- package/ts/webrtcLink.bun.spec.ts +45 -1
- package/ts/webrtcLink.spec.ts +45 -1
- package/ts/webrtcLink.ts +63 -19
- package/dist/SUPPORTED_CLIS-CuvT3tKq.js +0 -12
- package/dist/globalPidIndex-yvWZXeWT.js +0 -4
- package/dist/pidStore-V51mbgZY.js +0 -6
- package/dist/share-BvU4I8BI.js +0 -4
- package/dist/spawnGate-C8MSRt7X.js +0 -5
- package/dist/trayApp-Br2T2Yrb.js +0 -5
- package/dist/webrtcLink-DLaJfTcB.js +0 -45
package/ts/share.ts
CHANGED
|
@@ -147,7 +147,7 @@ export interface ShareOpts {
|
|
|
147
147
|
* generation/token), startShare mints a fresh room, persists it, and calls
|
|
148
148
|
* this so the caller can refresh its stored link. Leave unset for explicit
|
|
149
149
|
* webrtc:// URLs, which must NOT be silently rotated. */
|
|
150
|
-
onRotate?: (info: { room: string; link: string }) => void | Promise<void>;
|
|
150
|
+
onRotate?: (info: { room: string; link: string; joinLink: string }) => void | Promise<void>;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// The room+token persist like the serve token, so the share link (and any
|
|
@@ -183,7 +183,13 @@ export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<
|
|
|
183
183
|
|
|
184
184
|
function parseShareUrl(s: string): { room: string; token: string; host: string } {
|
|
185
185
|
const m = /^webrtc:\/\/([^:@/]+):([^@/]+)@(.+)$/.exec(s);
|
|
186
|
-
|
|
186
|
+
// Callers normalize any user-facing spelling (https://…/room/#…) to this
|
|
187
|
+
// internal form via toWebrtcUrl, so reaching here means a caller bug — point at
|
|
188
|
+
// the shape operators actually hold rather than at the internal one.
|
|
189
|
+
if (!m)
|
|
190
|
+
throw new Error(
|
|
191
|
+
`bad room url: ${s} (want a https://agent-yes.com/room/#room=<id>&s=e1.<secret> link)`,
|
|
192
|
+
);
|
|
187
193
|
return { room: m[1]!, token: m[2]!, host: m[3]! };
|
|
188
194
|
}
|
|
189
195
|
|
|
@@ -200,6 +206,27 @@ function formatShareLink(room: string, S: string, host: string): string {
|
|
|
200
206
|
return `${ui}/#${room}:${MARKER}${S}${suffix}`;
|
|
201
207
|
}
|
|
202
208
|
|
|
209
|
+
// The /room/ link for a room — the one you hand to ANOTHER machine to attach it
|
|
210
|
+
// to this room. Opened in a browser it's a guide page (which renders the install
|
|
211
|
+
// one-liner client-side from this very fragment, so the secret never reaches a
|
|
212
|
+
// server); passed to `ay serve --webrtc` it joins.
|
|
213
|
+
//
|
|
214
|
+
// The fragment is key=value (URLSearchParams) rather than the positional form
|
|
215
|
+
// /w/ uses: the fragment namespace is shared with #k=, #ch= and #launch=, and a
|
|
216
|
+
// positional grammar can only be told apart from those by a blocklist someone
|
|
217
|
+
// has to remember to update (see parseRoomHash in lab/ui/rtc.js). Bare `k=v&…`
|
|
218
|
+
// — no leading "?" — matches OAuth 2.0's implicit-grant fragment (RFC 6749
|
|
219
|
+
// §4.2.2), the same trick for the same reason: a credential the server must
|
|
220
|
+
// never see.
|
|
221
|
+
export function formatRoomLink(room: string, S: string, host: string): string {
|
|
222
|
+
const prod = host === DEFAULT_SIGHOST;
|
|
223
|
+
const ui = prod ? "https://agent-yes.com/room" : "http://localhost:7778/room";
|
|
224
|
+
// Values must be URL-safe: `s` is hex and `room` is [A-Za-z0-9_-], but encode
|
|
225
|
+
// anyway — URLSearchParams decodes on the way out and reads "+" as a space.
|
|
226
|
+
const q = `room=${encodeURIComponent(room)}&s=${encodeURIComponent(MARKER + S)}`;
|
|
227
|
+
return `${ui}/#${q}${prod ? "" : `&sig=${encodeURIComponent(host)}`}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
203
230
|
// Derive the shareable console link from a persisted/explicit webrtc://room:token@host
|
|
204
231
|
// URL WITHOUT starting a bridge, so `ay serve install` can print the same link the
|
|
205
232
|
// background daemon will serve. Throws on a legacy (unencrypted) room, mirroring
|
|
@@ -307,7 +334,7 @@ export async function importRTC(): Promise<any> {
|
|
|
307
334
|
* process exits, reconnecting signaling on drop. Returns the shareable link. */
|
|
308
335
|
export async function startShare(
|
|
309
336
|
opts: ShareOpts,
|
|
310
|
-
): Promise<{ room: string; link: string; close: () => void }> {
|
|
337
|
+
): Promise<{ room: string; link: string; joinLink: string; close: () => void }> {
|
|
311
338
|
const minted = !opts.url;
|
|
312
339
|
const sighost = opts.sighost ?? DEFAULT_SIGHOST;
|
|
313
340
|
const initial = opts.url
|
|
@@ -337,8 +364,12 @@ export async function startShare(
|
|
|
337
364
|
|
|
338
365
|
const wsScheme = host.startsWith("localhost") || host.startsWith("127.") ? "ws" : "wss";
|
|
339
366
|
const mkLink = () => formatShareLink(room, S, host);
|
|
367
|
+
// The /room/ link — handed to ANOTHER machine to attach it to this room,
|
|
368
|
+
// where the console link above is for a human to watch it.
|
|
369
|
+
const mkJoinLink = () => formatRoomLink(room, S, host);
|
|
340
370
|
let authToken = await deriveAuthToken(S, room, host);
|
|
341
371
|
let link = mkLink();
|
|
372
|
+
let joinLink = mkJoinLink();
|
|
342
373
|
|
|
343
374
|
const RTCPeerConnection = await importRTC();
|
|
344
375
|
|
|
@@ -356,6 +387,7 @@ export async function startShare(
|
|
|
356
387
|
S = parseSecret(token).s;
|
|
357
388
|
authToken = await deriveAuthToken(S, room, host);
|
|
358
389
|
link = mkLink();
|
|
390
|
+
joinLink = mkJoinLink();
|
|
359
391
|
// close() may have run during the await above — don't persist/announce or let
|
|
360
392
|
// the caller reconnect a room for a share that's shutting down.
|
|
361
393
|
if (closed) return false;
|
|
@@ -365,7 +397,7 @@ export async function startShare(
|
|
|
365
397
|
} catch {
|
|
366
398
|
/* best effort — in-memory rotation still lets new browsers join */
|
|
367
399
|
}
|
|
368
|
-
await opts.onRotate({ room, link });
|
|
400
|
+
await opts.onRotate({ room, link, joinLink });
|
|
369
401
|
return true;
|
|
370
402
|
};
|
|
371
403
|
|
|
@@ -885,5 +917,5 @@ export async function startShare(
|
|
|
885
917
|
}
|
|
886
918
|
for (const peerId of [...peers.keys()]) closePeer(peerId);
|
|
887
919
|
};
|
|
888
|
-
return { room, link, close };
|
|
920
|
+
return { room, link, joinLink, close };
|
|
889
921
|
}
|
package/ts/subcommands.ts
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import { randomBytes } from "crypto";
|
|
15
|
-
import { closeSync, constants as fsConstants, openSync } from "fs";
|
|
15
|
+
import { closeSync, constants as fsConstants, openSync, realpathSync } from "fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
16
17
|
import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
|
|
17
18
|
import ms from "ms";
|
|
18
19
|
import { homedir } from "os";
|
|
@@ -72,11 +73,20 @@ import { framePaste as frameAsPaste, shouldFramePaste } from "./bracketedPaste.t
|
|
|
72
73
|
import {
|
|
73
74
|
ageMatchesRegistration,
|
|
74
75
|
findAgentAncestor,
|
|
76
|
+
pidOwnershipVerdict,
|
|
75
77
|
readAncestryTable,
|
|
76
78
|
type SenderVia,
|
|
77
79
|
} from "./senderAncestry.ts";
|
|
78
80
|
import yargs from "yargs";
|
|
79
81
|
import { type ResolvedRemote, readRemotes, resolveRemoteSpec } from "./remotes.ts";
|
|
82
|
+
import {
|
|
83
|
+
noteRemoteResult,
|
|
84
|
+
pruneRemoteHealth,
|
|
85
|
+
readRemoteHealth,
|
|
86
|
+
remoteBackoffMs,
|
|
87
|
+
shouldSkipRemote,
|
|
88
|
+
writeRemoteHealth,
|
|
89
|
+
} from "./remoteHealth.ts";
|
|
80
90
|
import { isWebrtcSpec } from "./webrtcLink.ts";
|
|
81
91
|
import { withIpcLock } from "./ipcLock.ts";
|
|
82
92
|
|
|
@@ -289,6 +299,35 @@ function observedSender(): ObservedSender {
|
|
|
289
299
|
};
|
|
290
300
|
}
|
|
291
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The repository a directory belongs to, as git itself defines it: the common
|
|
304
|
+
* dir, which every linked worktree of one repository shares and no separate
|
|
305
|
+
* clone does. Null when the path is not in a repository or git cannot answer.
|
|
306
|
+
*
|
|
307
|
+
* Synchronous and cached: it is consulted at most twice per send, and only on
|
|
308
|
+
* the path where the cheaper directory check already failed.
|
|
309
|
+
*/
|
|
310
|
+
const _commonDirCache = new Map<string, string | null>();
|
|
311
|
+
function gitCommonDir(dir: string): string | null {
|
|
312
|
+
const hit = _commonDirCache.get(dir);
|
|
313
|
+
if (hit !== undefined) return hit;
|
|
314
|
+
let out: string | null = null;
|
|
315
|
+
try {
|
|
316
|
+
out =
|
|
317
|
+
execFileSync("git", ["-C", dir, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
|
|
318
|
+
encoding: "utf8",
|
|
319
|
+
timeout: 5000,
|
|
320
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
321
|
+
})
|
|
322
|
+
.trim()
|
|
323
|
+
.replace(/\/+$/, "") || null;
|
|
324
|
+
} catch {
|
|
325
|
+
out = null;
|
|
326
|
+
}
|
|
327
|
+
_commonDirCache.set(dir, out);
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
292
331
|
async function resolveSender(): Promise<GlobalPidRecord | null> {
|
|
293
332
|
return (await resolveSenderVia()).agent;
|
|
294
333
|
}
|
|
@@ -355,12 +394,78 @@ async function computeSenderVia(): Promise<{ agent: GlobalPidRecord | null; via:
|
|
|
355
394
|
// The honest path is unchanged in OUTCOME: the wrapper that injects
|
|
356
395
|
// AGENT_YES_PID is an ancestor of the `ay send` it spawns, so a normal lane
|
|
357
396
|
// corroborates and renders exactly as before.
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
// and the
|
|
363
|
-
|
|
397
|
+
// The claimed lane is our ancestor: claim and kernel agree.
|
|
398
|
+
if (inherited?.pid === declared.pid) return { agent: declared, via: "env" };
|
|
399
|
+
|
|
400
|
+
// It is not. That alone does not say which of two very different things is
|
|
401
|
+
// happening, and the earlier rule — "is some OTHER registered lane my
|
|
402
|
+
// ancestor" — picked the wrong discriminator. Measured on real traffic it
|
|
403
|
+
// was inverted: it fired on a lane's own nested processes (same tree,
|
|
404
|
+
// benign) and stayed silent while a different worktree sent two documents
|
|
405
|
+
// under this lane's name.
|
|
406
|
+
//
|
|
407
|
+
// The working directory separates them. A lane's own detached helper runs
|
|
408
|
+
// inside that lane's tree; a process in a DIFFERENT tree claiming the lane
|
|
409
|
+
// is the case the marker exists for. cwd is observed, not asserted — the
|
|
410
|
+
// body cannot set it.
|
|
411
|
+
// BOTH sides are realpath'd first. macOS resolves /tmp -> /private/tmp and
|
|
412
|
+
// /var -> /private/var, so a lane registered under a symlinked path would
|
|
413
|
+
// otherwise never appear to contain its own processes and every honest send
|
|
414
|
+
// from it would be accused. Caught by a same-tree test that went loud.
|
|
415
|
+
// Best-effort: an unreadable path falls back to the raw string rather than
|
|
416
|
+
// failing the send.
|
|
417
|
+
const real = (p2: string): string => {
|
|
418
|
+
try {
|
|
419
|
+
return realpathSync(p2).replace(/\/+$/, "");
|
|
420
|
+
} catch {
|
|
421
|
+
return p2.replace(/\/+$/, "");
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
const here = real(process.cwd());
|
|
425
|
+
const claimedRoot = declared.cwd ? real(declared.cwd) : "";
|
|
426
|
+
const insideClaimedTree =
|
|
427
|
+
Boolean(claimedRoot) && (here === claimedRoot || here.startsWith(claimedRoot + path.sep));
|
|
428
|
+
// "Inside the tree" is not the same as "belongs to that lane". A lane
|
|
429
|
+
// routinely works in a LINKED WORKTREE that is a sibling directory, not a
|
|
430
|
+
// child — `~/ws/org/_wt/feature-x` alongside `~/ws/org/repo/tree/dev`. A
|
|
431
|
+
// bare `ay send` from a shell there inherits AGENT_YES_PID honestly and
|
|
432
|
+
// would be accused on a path check alone.
|
|
433
|
+
//
|
|
434
|
+
// git already answers this exactly: linked worktrees of one repository
|
|
435
|
+
// share a git common dir, while a separate clone has its own. Measured on
|
|
436
|
+
// the fleet that reported it — the sibling worktree resolved to the lane's
|
|
437
|
+
// own `.git`, and the worktree that had been misattributing resolved to a
|
|
438
|
+
// different one. So the same probe separates the honest case from the case
|
|
439
|
+
// this marker exists for, which a "same parent directory" heuristic would
|
|
440
|
+
// not: both live under the same ancestor.
|
|
441
|
+
//
|
|
442
|
+
// Only consulted when the path check already failed, so the honest common
|
|
443
|
+
// path pays nothing.
|
|
444
|
+
// CONTAINMENT, not equality. A lane's submodule worktrees have a common dir
|
|
445
|
+
// NESTED inside the lane's own:
|
|
446
|
+
//
|
|
447
|
+
// lane …/tree/dev/.git
|
|
448
|
+
// its submodule wt …/tree/dev/.git/modules/lib/desktop <- descendant
|
|
449
|
+
// a separate clone …/tree/billings/.git <- neither
|
|
450
|
+
//
|
|
451
|
+
// Equality would mark the first case LOUD — the same false positive one
|
|
452
|
+
// layer down, reported from the fleet that runs that shape. Checked in both
|
|
453
|
+
// directions because the mirror layout (a lane registered in the submodule
|
|
454
|
+
// worktree, sending from the parent tree) is equally legitimate and would
|
|
455
|
+
// otherwise wait to be discovered by firing. A separate clone is under
|
|
456
|
+
// neither, so detection is unchanged.
|
|
457
|
+
const sameRepo = () => {
|
|
458
|
+
if (!claimedRoot) return false;
|
|
459
|
+
const a = gitCommonDir(here);
|
|
460
|
+
const b = gitCommonDir(claimedRoot);
|
|
461
|
+
if (a === null || b === null) return false;
|
|
462
|
+
return a === b || a.startsWith(b + path.sep) || b.startsWith(a + path.sep);
|
|
463
|
+
};
|
|
464
|
+
if (!insideClaimedTree && !sameRepo()) return { agent: declared, via: "env-uncorroborated" };
|
|
465
|
+
|
|
466
|
+
// Inside the claimed lane's own tree, or nothing to compare against:
|
|
467
|
+
// unverifiable, not suspicious, and must not be dressed as it.
|
|
468
|
+
return { agent: declared, via: "env-unverified" };
|
|
364
469
|
}
|
|
365
470
|
// A set-but-unresolvable AGENT_YES_PID (stale env, aged-out record) is not a
|
|
366
471
|
// reason to stop: the process tree can still say who this is.
|
|
@@ -687,10 +792,16 @@ export function envelopeAttribution(via: SenderVia): string {
|
|
|
687
792
|
// Derived from the process tree because the wrapper's env was absent.
|
|
688
793
|
return " via process-tree";
|
|
689
794
|
case "env-uncorroborated":
|
|
690
|
-
// The
|
|
691
|
-
//
|
|
692
|
-
//
|
|
795
|
+
// The claim and the process tree name DIFFERENT lanes. Loud, because this
|
|
796
|
+
// is the case a receiver acting on sender weight must not be handed as a
|
|
797
|
+
// fact — and loud only here, so it keeps meaning something.
|
|
693
798
|
return " UNCORROBORATED-SENDER";
|
|
799
|
+
case "env-unverified":
|
|
800
|
+
// Nothing to disagree with. Silent in the envelope: the receiver gains
|
|
801
|
+
// nothing actionable from "we could not check", and a marker on honest
|
|
802
|
+
// traffic is how the loud one stops being read. It is still recorded in
|
|
803
|
+
// from_via for anyone who wants to weigh it.
|
|
804
|
+
return "";
|
|
694
805
|
default:
|
|
695
806
|
return "";
|
|
696
807
|
}
|
|
@@ -1006,14 +1117,28 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
|
|
|
1006
1117
|
}
|
|
1007
1118
|
case "gc": {
|
|
1008
1119
|
const { gcOldBinaryDirs } = await import("./rustBinary.ts");
|
|
1009
|
-
const
|
|
1010
|
-
|
|
1120
|
+
const { gcLogs } = await import("./globalPidIndex.ts");
|
|
1121
|
+
const bins = gcOldBinaryDirs();
|
|
1122
|
+
// Logs are the bigger leak of the two: binary dirs are ~20-30 MiB per
|
|
1123
|
+
// release, while a single long-lived session's raw log can pass 500.
|
|
1124
|
+
const logs = await gcLogs();
|
|
1125
|
+
|
|
1126
|
+
if (bins.removed.length === 0) {
|
|
1011
1127
|
process.stdout.write("no old agent-yes binary cache dirs to remove\n");
|
|
1012
1128
|
} else {
|
|
1013
|
-
for (const v of
|
|
1014
|
-
const mib = (
|
|
1129
|
+
for (const v of bins.removed) process.stdout.write(`removed ${v}\n`);
|
|
1130
|
+
const mib = (bins.freedBytes / 1024 / 1024).toFixed(1);
|
|
1131
|
+
process.stdout.write(
|
|
1132
|
+
`freed ${mib} MiB (${bins.freedBytes} bytes) across ${bins.removed.length} version dir(s)\n`,
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
if (logs.removed.length === 0) {
|
|
1137
|
+
process.stdout.write("no stale session logs to remove\n");
|
|
1138
|
+
} else {
|
|
1139
|
+
const mib = (logs.freedBytes / 1024 / 1024).toFixed(1);
|
|
1015
1140
|
process.stdout.write(
|
|
1016
|
-
`freed ${mib} MiB (${
|
|
1141
|
+
`freed ${mib} MiB (${logs.freedBytes} bytes) across ${logs.removed.length} session log file(s)\n`,
|
|
1017
1142
|
);
|
|
1018
1143
|
}
|
|
1019
1144
|
return 0;
|
|
@@ -1155,7 +1280,7 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
|
|
|
1155
1280
|
` ay result <keyword> [--wait] pull an agent's structured result envelope\n` +
|
|
1156
1281
|
` ay result set '<json>' (inside an agent) deposit your result envelope\n` +
|
|
1157
1282
|
` ay reap kill process groups leaked by dead agents\n` +
|
|
1158
|
-
` ay gc
|
|
1283
|
+
` ay gc reclaim old-version binary cache dirs + stale session logs\n` +
|
|
1159
1284
|
` ay dsh-legacy [args...] launch the DeepSeek Harness terminal client (dsh-tui)\n` +
|
|
1160
1285
|
wsLines +
|
|
1161
1286
|
`\n` +
|
|
@@ -1790,6 +1915,14 @@ async function fetchRemoteRecordsRaw(
|
|
|
1790
1915
|
url: string,
|
|
1791
1916
|
token: string,
|
|
1792
1917
|
opts: { all: boolean; active: boolean; keyword?: string },
|
|
1918
|
+
/**
|
|
1919
|
+
* Set true when the host ANSWERED, false when it did not. A healthy host with
|
|
1920
|
+
* no agents — or one where `--keyword` matched nothing — returns [] exactly
|
|
1921
|
+
* like a dead one, so row count cannot stand in for reachability: using it
|
|
1922
|
+
* would mark a live-but-empty host failed and hide it for up to 32 minutes,
|
|
1923
|
+
* which is the failure this whole change exists to avoid.
|
|
1924
|
+
*/
|
|
1925
|
+
onReachable?: (ok: boolean) => void,
|
|
1793
1926
|
): Promise<any[]> {
|
|
1794
1927
|
const params = new URLSearchParams();
|
|
1795
1928
|
if (opts.all) params.set("all", "1");
|
|
@@ -1810,9 +1943,13 @@ async function fetchRemoteRecordsRaw(
|
|
|
1810
1943
|
headers: { Authorization: `Bearer ${bearer}` },
|
|
1811
1944
|
signal: AbortSignal.timeout(8000),
|
|
1812
1945
|
});
|
|
1946
|
+
// A 4xx/5xx still means the host is THERE and talking; only a throw (DNS,
|
|
1947
|
+
// refused connection, connect timeout, aborted bridge) means unreachable.
|
|
1948
|
+
onReachable?.(true);
|
|
1813
1949
|
if (!res.ok) return [];
|
|
1814
1950
|
return (await res.json()) as any[];
|
|
1815
1951
|
} catch {
|
|
1952
|
+
onReachable?.(false);
|
|
1816
1953
|
return [];
|
|
1817
1954
|
} finally {
|
|
1818
1955
|
bridge?.close();
|
|
@@ -1833,16 +1970,47 @@ async function runAllRemotesLs(opts: {
|
|
|
1833
1970
|
cwdScope: null,
|
|
1834
1971
|
};
|
|
1835
1972
|
|
|
1973
|
+
// A remote that is not answering costs a full connect timeout (25s for a
|
|
1974
|
+
// WebRTC spec) and returns nothing, and this gathers them together — so two
|
|
1975
|
+
// dead entries made every `ay ls` here take 27s instead of 5s. Skip a host
|
|
1976
|
+
// that has been failing, on a doubling backoff, and keep LISTING it as
|
|
1977
|
+
// unreachable so the config stays honest and the operator can see it is being
|
|
1978
|
+
// skipped rather than silently dropped. See ts/remoteHealth.ts.
|
|
1979
|
+
const health = await readRemoteHealth();
|
|
1980
|
+
const healthNow = Date.now();
|
|
1981
|
+
const skipped = new Set(
|
|
1982
|
+
[...remotes.keys()].filter((alias) => shouldSkipRemote(health[alias], healthNow)),
|
|
1983
|
+
);
|
|
1984
|
+
const probed = [...remotes.entries()].filter(([alias]) => !skipped.has(alias));
|
|
1985
|
+
|
|
1836
1986
|
const [localResult, ...remoteResults] = await Promise.allSettled([
|
|
1837
1987
|
listRecords(opts.keyword, localOpts).then((recs) => ({
|
|
1838
1988
|
host: "local",
|
|
1839
1989
|
records: recs as any[],
|
|
1840
1990
|
})),
|
|
1841
|
-
...
|
|
1842
|
-
|
|
1843
|
-
|
|
1991
|
+
...probed.map(([alias, cfg]) => {
|
|
1992
|
+
let reachable = false;
|
|
1993
|
+
return fetchRemoteRecordsRaw(cfg.url, cfg.token, opts, (ok) => {
|
|
1994
|
+
reachable = ok;
|
|
1995
|
+
}).then((records) => ({ host: alias, records, ok: reachable }));
|
|
1996
|
+
}),
|
|
1844
1997
|
]);
|
|
1845
1998
|
|
|
1999
|
+
// Record what we learned, then persist once. A host that answered clears its
|
|
2000
|
+
// streak; one that did not extends it. Best-effort: a health file we cannot
|
|
2001
|
+
// write must never break `ay ls`.
|
|
2002
|
+
{
|
|
2003
|
+
// Prune first, so an alias removed from remotes.yaml stops being carried
|
|
2004
|
+
// forever. `remotes` is the live configured set.
|
|
2005
|
+
const next = pruneRemoteHealth({ ...health }, remotes.keys());
|
|
2006
|
+
for (const [i, [alias]] of probed.entries()) {
|
|
2007
|
+
const res = remoteResults[i];
|
|
2008
|
+
const ok = res?.status === "fulfilled" && (res.value as any).ok === true;
|
|
2009
|
+
next[alias] = noteRemoteResult(health[alias], ok, healthNow);
|
|
2010
|
+
}
|
|
2011
|
+
await writeRemoteHealth(next).catch(() => {});
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1846
2014
|
// Group by host in a stable order (local first, then each remote alias in
|
|
1847
2015
|
// config order), so the aggregated table reads top-down per machine.
|
|
1848
2016
|
const byHost: { host: string; records: any[] }[] = [];
|
|
@@ -1882,6 +2050,28 @@ async function runAllRemotesLs(opts: {
|
|
|
1882
2050
|
);
|
|
1883
2051
|
byHost.push({ host: "local", records: enriched });
|
|
1884
2052
|
}
|
|
2053
|
+
// A skipped host is REPORTED, not dropped. Silently omitting it would make a
|
|
2054
|
+
// machine that can come back disappear from the fleet view with nothing to
|
|
2055
|
+
// notice — the same failure as deleting it from the config, which is why we
|
|
2056
|
+
// did not do that. One line to stderr per skipped host, so `--json` and any
|
|
2057
|
+
// parser downstream stay unaffected.
|
|
2058
|
+
for (const alias of skipped) {
|
|
2059
|
+
const h = health[alias];
|
|
2060
|
+
// Time REMAINING, not the window length: a host with one second left would
|
|
2061
|
+
// otherwise report "retrying in up to 32m", which is true of the window and
|
|
2062
|
+
// false of the wait, and the operator is reading this to decide whether to
|
|
2063
|
+
// wait or force a probe.
|
|
2064
|
+
const leftMs = Math.max(
|
|
2065
|
+
0,
|
|
2066
|
+
remoteBackoffMs(h?.streak ?? 1) - (healthNow - (h?.lastFailedAt ?? healthNow)),
|
|
2067
|
+
);
|
|
2068
|
+
const left =
|
|
2069
|
+
leftMs >= 60_000 ? `${Math.round(leftMs / 60_000)}m` : `${Math.ceil(leftMs / 1000)}s`;
|
|
2070
|
+
process.stderr.write(
|
|
2071
|
+
`${alias}: unreachable — skipped after ${h?.streak ?? 0} failed attempt(s), ` +
|
|
2072
|
+
`retrying in ${left} (ay ls ${alias} to force a probe now)\n`,
|
|
2073
|
+
);
|
|
2074
|
+
}
|
|
1885
2075
|
for (const res of remoteResults) {
|
|
1886
2076
|
if (res.status === "fulfilled")
|
|
1887
2077
|
byHost.push({ host: res.value.host, records: res.value.records });
|
|
@@ -5415,6 +5605,36 @@ async function cmdRestart(rest: string[]): Promise<number> {
|
|
|
5415
5605
|
let exited = await waitForExit(record.pid, 30_000);
|
|
5416
5606
|
if (!exited) {
|
|
5417
5607
|
// Wouldn't go gracefully — SIGKILL the pid (the reaper sweeps its pgid).
|
|
5608
|
+
//
|
|
5609
|
+
// But ONLY if the process at that number is still plausibly the agent we
|
|
5610
|
+
// registered. pids are reused: a row can outlive its agent and name
|
|
5611
|
+
// whatever now holds the number. This host carried a row 89 days old,
|
|
5612
|
+
// surviving a reboot, whose pid was macOS `login` running as root.
|
|
5613
|
+
//
|
|
5614
|
+
// Nothing in this code was declining to kill that. The OS was, with
|
|
5615
|
+
// EPERM, and only because the owner differed — a same-user recycled pid
|
|
5616
|
+
// would have been killed. Deciding is the code's job, not the kernel's.
|
|
5617
|
+
//
|
|
5618
|
+
// Same age test the send path uses (#460): a process that started AFTER
|
|
5619
|
+
// its registration cannot be the thing that registered.
|
|
5620
|
+
//
|
|
5621
|
+
// Refuse on EVIDENCE, not on ignorance. "Could not establish" is a third
|
|
5622
|
+
// answer, not a quiet synonym for "reused": win32 has no process table
|
|
5623
|
+
// reader, so treating unknown as reused would not guard this kill, it
|
|
5624
|
+
// would delete restart's force-kill from that platform entirely. Where
|
|
5625
|
+
// we cannot tell, behaviour is exactly what it was before this guard.
|
|
5626
|
+
const table = await readAncestryTable();
|
|
5627
|
+
const owns = pidOwnershipVerdict(table?.get(record.pid)?.ageSecs, record.started_at);
|
|
5628
|
+
if (owns === "reused") {
|
|
5629
|
+
process.stderr.write(
|
|
5630
|
+
`pid ${record.pid} is alive but is NOT the agent that registered it — ` +
|
|
5631
|
+
`its process is younger than the registration, so the number was reused. ` +
|
|
5632
|
+
`Refusing to kill it.\n` +
|
|
5633
|
+
` check with: ps -p ${record.pid} -o pid=,user=,comm=\n` +
|
|
5634
|
+
` retire the stale row instead: ay stop ${record.pid}\n`,
|
|
5635
|
+
);
|
|
5636
|
+
return 1;
|
|
5637
|
+
}
|
|
5418
5638
|
try {
|
|
5419
5639
|
process.kill(record.pid, "SIGKILL");
|
|
5420
5640
|
} catch {
|
package/ts/terminal/browser.ts
CHANGED
|
@@ -233,6 +233,10 @@ export class AyTerminal {
|
|
|
233
233
|
// Interactive input (opt-in): forward keystrokes to the agent's stdin as raw
|
|
234
234
|
// bytes (code:"none" — the terminal itself sends \r). Fails closed to a mirror
|
|
235
235
|
// on a 403 (a read-only token), like the rgui embed's attachStdin/onDenied.
|
|
236
|
+
//
|
|
237
|
+
// `raw: true` marks this a terminal wire, not a message (#453). Without it
|
|
238
|
+
// every keystroke and SGR mouse report becomes an inbox row, and the inbox
|
|
239
|
+
// is capacity-capped — a mouse scrolled over the pane evicts real messages.
|
|
236
240
|
if (!this.readOnly) {
|
|
237
241
|
this.term.onData((data: string) => {
|
|
238
242
|
if (!this.writable) return;
|
|
@@ -240,7 +244,7 @@ export class AyTerminal {
|
|
|
240
244
|
.fetch(this.url("/api/send"), {
|
|
241
245
|
method: "POST",
|
|
242
246
|
headers: { "content-type": "application/json" },
|
|
243
|
-
body: JSON.stringify({ keyword: this.pid, msg: data, code: "none" }),
|
|
247
|
+
body: JSON.stringify({ keyword: this.pid, msg: data, code: "none", raw: true }),
|
|
244
248
|
})
|
|
245
249
|
.then((r: any) => {
|
|
246
250
|
if (r.status === 403 || r.status === 401) this.revokeWrite();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
|
-
import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
|
|
2
|
+
import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
|
|
3
3
|
|
|
4
4
|
// A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
|
|
5
5
|
const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
|
|
@@ -42,4 +42,48 @@ describe("parseWebrtcLink", () => {
|
|
|
42
42
|
expect(parseWebrtcLink("just-an-alias")).toBeNull();
|
|
43
43
|
expect(parseWebrtcLink("https://agent-yes.com/w/#noColonHere")).toBeNull();
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("parses the key=value /room/ fragment", () => {
|
|
47
|
+
const r = parseWebrtcLink(`https://agent-yes.com/room/#room=r223104&s=${SECRET}`);
|
|
48
|
+
expect(r).toMatchObject({ room: "r223104", host: DEFAULT_SIGHOST });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("honors sig= and tolerates a leading ? in the key=value form", () => {
|
|
52
|
+
expect(
|
|
53
|
+
parseWebrtcLink(`https://agent-yes.com/room/#?room=r1&s=${SECRET}&sig=sig.example.com`),
|
|
54
|
+
).toMatchObject({ room: "r1", host: "sig.example.com" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null when the key=value form is missing room or s", () => {
|
|
58
|
+
expect(parseWebrtcLink(`https://agent-yes.com/room/#room=r1`)).toBeNull();
|
|
59
|
+
expect(parseWebrtcLink(`https://agent-yes.com/room/#s=${SECRET}`)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The fragment namespace is shared with #k=, #ch= and #launch= (see
|
|
63
|
+
// parseRoomHash in lab/ui/rtc.js); a foreign key set must not be read as a room.
|
|
64
|
+
it("does not mistake another feature's hash for a room", () => {
|
|
65
|
+
expect(parseWebrtcLink("https://agent-yes.com/w/#k=deadbeef")).toBeNull();
|
|
66
|
+
expect(parseWebrtcLink("https://agent-yes.com/w/#launch=claude")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("toWebrtcUrl", () => {
|
|
71
|
+
it("normalizes every accepted spelling to the internal webrtc:// form", () => {
|
|
72
|
+
const want = `webrtc://r1:${SECRET}@${DEFAULT_SIGHOST}`;
|
|
73
|
+
expect(toWebrtcUrl(`https://agent-yes.com/room/#room=r1&s=${SECRET}`)).toBe(want);
|
|
74
|
+
expect(toWebrtcUrl(`https://agent-yes.com/w/#r1:${SECRET}`)).toBe(want);
|
|
75
|
+
expect(toWebrtcUrl(want)).toBe(want);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// parseSecret strips the "e1." marker; rebuilding from the parsed `s` would
|
|
79
|
+
// hand a legacy markerless room a marker it never had, changing the token the
|
|
80
|
+
// signaling DO pinned the room to.
|
|
81
|
+
it("round-trips a legacy markerless token unchanged", () => {
|
|
82
|
+
const legacy = `webrtc://r1:plaintexttoken@${DEFAULT_SIGHOST}`;
|
|
83
|
+
expect(toWebrtcUrl(legacy)).toBe(legacy);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null for a non-link", () => {
|
|
87
|
+
expect(toWebrtcUrl("just-an-alias")).toBeNull();
|
|
88
|
+
});
|
|
45
89
|
});
|
package/ts/webrtcLink.spec.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink } from "./webrtcLink.ts";
|
|
2
|
+
import { DEFAULT_SIGHOST, isWebrtcSpec, parseWebrtcLink, toWebrtcUrl } from "./webrtcLink.ts";
|
|
3
3
|
|
|
4
4
|
// A representative v2 share secret (e1.<64 hex>); parseSecret keeps the hex `s`.
|
|
5
5
|
const SECRET = "e1.982610a3034f065bfe9700037b306a6afeb7dc48567064058e6c4bbc09e502c2";
|
|
@@ -42,4 +42,48 @@ describe("parseWebrtcLink", () => {
|
|
|
42
42
|
expect(parseWebrtcLink("just-an-alias")).toBeNull();
|
|
43
43
|
expect(parseWebrtcLink("https://agent-yes.com/w/#noColonHere")).toBeNull();
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("parses the key=value /room/ fragment", () => {
|
|
47
|
+
const r = parseWebrtcLink(`https://agent-yes.com/room/#room=r223104&s=${SECRET}`);
|
|
48
|
+
expect(r).toMatchObject({ room: "r223104", host: DEFAULT_SIGHOST });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("honors sig= and tolerates a leading ? in the key=value form", () => {
|
|
52
|
+
expect(
|
|
53
|
+
parseWebrtcLink(`https://agent-yes.com/room/#?room=r1&s=${SECRET}&sig=sig.example.com`),
|
|
54
|
+
).toMatchObject({ room: "r1", host: "sig.example.com" });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns null when the key=value form is missing room or s", () => {
|
|
58
|
+
expect(parseWebrtcLink(`https://agent-yes.com/room/#room=r1`)).toBeNull();
|
|
59
|
+
expect(parseWebrtcLink(`https://agent-yes.com/room/#s=${SECRET}`)).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// The fragment namespace is shared with #k=, #ch= and #launch= (see
|
|
63
|
+
// parseRoomHash in lab/ui/rtc.js); a foreign key set must not be read as a room.
|
|
64
|
+
it("does not mistake another feature's hash for a room", () => {
|
|
65
|
+
expect(parseWebrtcLink("https://agent-yes.com/w/#k=deadbeef")).toBeNull();
|
|
66
|
+
expect(parseWebrtcLink("https://agent-yes.com/w/#launch=claude")).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("toWebrtcUrl", () => {
|
|
71
|
+
it("normalizes every accepted spelling to the internal webrtc:// form", () => {
|
|
72
|
+
const want = `webrtc://r1:${SECRET}@${DEFAULT_SIGHOST}`;
|
|
73
|
+
expect(toWebrtcUrl(`https://agent-yes.com/room/#room=r1&s=${SECRET}`)).toBe(want);
|
|
74
|
+
expect(toWebrtcUrl(`https://agent-yes.com/w/#r1:${SECRET}`)).toBe(want);
|
|
75
|
+
expect(toWebrtcUrl(want)).toBe(want);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// parseSecret strips the "e1." marker; rebuilding from the parsed `s` would
|
|
79
|
+
// hand a legacy markerless room a marker it never had, changing the token the
|
|
80
|
+
// signaling DO pinned the room to.
|
|
81
|
+
it("round-trips a legacy markerless token unchanged", () => {
|
|
82
|
+
const legacy = `webrtc://r1:plaintexttoken@${DEFAULT_SIGHOST}`;
|
|
83
|
+
expect(toWebrtcUrl(legacy)).toBe(legacy);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null for a non-link", () => {
|
|
87
|
+
expect(toWebrtcUrl("just-an-alias")).toBeNull();
|
|
88
|
+
});
|
|
45
89
|
});
|