agent-yes 1.201.0 → 1.202.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/default.config.yaml +8 -8
- package/dist/{SUPPORTED_CLIS-CnGjmp53.js → SUPPORTED_CLIS-Cc4YcXL_.js} +2 -2
- package/dist/SUPPORTED_CLIS-ChdPabFX.js +9 -0
- package/dist/{agentShare-lDreSLQE.js → agentShare-e4ErqbAZ.js} +2 -2
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-C4VzLmmO.js → notifyDaemon-Bo-RiQ2N.js} +2 -2
- package/dist/{rustBinary-CVZsX0za.js → rustBinary-Dd7-HOFy.js} +2 -2
- package/dist/{schedule-hdbXBwpG.js → schedule-Hu3krhNK.js} +4 -4
- package/dist/{serve-DEEesVYn.js → serve-D4B43UQC.js} +12 -12
- package/dist/{setup-DZbnDas9.js → setup-hUonzP6V.js} +2 -2
- package/dist/{subcommands-IwpLJA-n.js → subcommands-DQmW41_L.js} +1 -1
- package/dist/{subcommands-De-OHc0k.js → subcommands-DbiyHGKA.js} +53 -11
- package/dist/{ts-ZOEYyQAX.js → ts--ZOQIaTr.js} +2 -2
- package/dist/{versionChecker-CBtAasFe.js → versionChecker-D2egG8Wk.js} +2 -2
- package/dist/{ws-DLiI3C6R.js → ws-CygS4IEs.js} +2 -2
- package/lab/ui/console-logic.js +32 -2
- package/lab/ui/index.html +64 -20
- package/lab/ui/qrcode.js +548 -565
- package/lab/ui/room-client.js +70 -91
- package/lab/ui/sw.js +17 -7
- package/package.json +7 -7
- package/scripts/build-rgui.ts +3 -1
- package/ts/agentShare.spec.ts +5 -3
- package/ts/badges.spec.ts +84 -1
- package/ts/badges.ts +69 -5
- package/ts/notifyDaemon.spec.ts +2 -8
- package/ts/notifyDaemon.ts +32 -33
- package/ts/notifyInbox.spec.ts +2 -1
- package/ts/notifyRouter.spec.ts +7 -11
- package/ts/notifyStore.spec.ts +4 -1
- package/ts/notifyStore.ts +18 -14
- package/ts/serve.spec.ts +4 -6
- package/ts/serve.ts +69 -17
- package/ts/subcommands.ts +46 -18
- package/ts/ws.spec.ts +31 -7
- package/ts/ws.ts +15 -4
- package/dist/SUPPORTED_CLIS-BbWt8-ON.js +0 -9
package/ts/badges.ts
CHANGED
|
@@ -11,11 +11,20 @@
|
|
|
11
11
|
export interface BadgeDef {
|
|
12
12
|
/** Stable id, used as the wire value and as the key when re-deriving the label. */
|
|
13
13
|
id: string;
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* Short chip text, e.g. "goal". Keep it a couple of characters — the chip is
|
|
16
|
+
* tiny. May contain `$1`, replaced with the pattern's first capture group
|
|
17
|
+
* (see the dynamic footer-counter defs below).
|
|
18
|
+
*/
|
|
15
19
|
label: string;
|
|
16
20
|
/** Tooltip shown on hover, explaining what the badge means. */
|
|
17
21
|
title: string;
|
|
18
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Matched against the tail-rendered screen text (lines joined with \n).
|
|
24
|
+
* A pattern with a capture group makes the badge DYNAMIC: the wire id
|
|
25
|
+
* becomes `id:capture` (e.g. "shells:4 shells") and `$1` in label/title is
|
|
26
|
+
* substituted with the captured text when the chip is rendered.
|
|
27
|
+
*/
|
|
19
28
|
pattern: RegExp;
|
|
20
29
|
}
|
|
21
30
|
|
|
@@ -46,15 +55,55 @@ export const BADGE_DEFS: BadgeDef[] = [
|
|
|
46
55
|
title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
|
|
47
56
|
pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i,
|
|
48
57
|
},
|
|
58
|
+
// ---- Dynamic footer counters -------------------------------------------
|
|
59
|
+
// claude's status footer lists live counters between "·" separators, e.g.
|
|
60
|
+
// ⏸ manual mode on · 1 shell · ctrl+t to hide tasks · ← for agents · ↓ to manage
|
|
61
|
+
// ⏸ manual mode on · 3 monitors · esc to interrupt · ↓ to manage
|
|
62
|
+
// ⏸ manual mode on · ? for shortcuts · ← 3 agents
|
|
63
|
+
// ⏸ manual mode on · PR #310
|
|
64
|
+
// Each pattern anchors on that chrome (the "· " separator / the "←" arrow),
|
|
65
|
+
// not the bare phrase, so ordinary conversation text about "4 shells" can't
|
|
66
|
+
// light the chip. The capture keeps the CLI's own singular/plural wording,
|
|
67
|
+
// so the chip reads exactly like the footer ("1 shell", "4 shells").
|
|
68
|
+
{
|
|
69
|
+
id: "shells",
|
|
70
|
+
label: "$1",
|
|
71
|
+
title: "Background shells running in this session ($1 in the CLI footer)",
|
|
72
|
+
pattern: /· (\d+ shells?)(?= ·|\s*$)/m,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "monitors",
|
|
76
|
+
label: "$1",
|
|
77
|
+
title: "Active Monitor watchers in this session ($1 in the CLI footer)",
|
|
78
|
+
pattern: /· (\d+ monitors?)(?= ·|\s*$)/m,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: "bg-agents",
|
|
82
|
+
label: "$1",
|
|
83
|
+
title: "Background subagents running in this session ($1 in the CLI footer)",
|
|
84
|
+
pattern: /← (\d+ agents?)(?= ·|\s*$)/m,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: "pr",
|
|
88
|
+
label: "$1",
|
|
89
|
+
title: "This session is linked to a GitHub pull request ($1 in the CLI footer)",
|
|
90
|
+
pattern: /· (PR #\d+)(?= ·|\s*$)/m,
|
|
91
|
+
},
|
|
49
92
|
];
|
|
50
93
|
|
|
51
94
|
/**
|
|
52
95
|
* Which badge ids match the given rendered screen lines. Pure so it's
|
|
53
|
-
* unit-tested without a live PTY/log file.
|
|
96
|
+
* unit-tested without a live PTY/log file. A def whose pattern captured a
|
|
97
|
+
* group yields `id:capture` (e.g. "shells:4 shells") so the count travels
|
|
98
|
+
* with the id over the wire; static defs yield the bare id as before.
|
|
54
99
|
*/
|
|
55
100
|
export function matchBadges(lines: string[], defs: BadgeDef[] = BADGE_DEFS): string[] {
|
|
56
101
|
const text = lines.join("\n");
|
|
57
|
-
return defs.
|
|
102
|
+
return defs.flatMap((d) => {
|
|
103
|
+
const m = d.pattern.exec(text);
|
|
104
|
+
if (!m) return [];
|
|
105
|
+
return [m[1] !== undefined ? `${d.id}:${m[1]}` : d.id];
|
|
106
|
+
});
|
|
58
107
|
}
|
|
59
108
|
|
|
60
109
|
/**
|
|
@@ -72,5 +121,20 @@ export const TYPING_BADGE: BadgeDef = {
|
|
|
72
121
|
};
|
|
73
122
|
|
|
74
123
|
export function badgeDef(id: string, defs: BadgeDef[] = BADGE_DEFS): BadgeDef | undefined {
|
|
75
|
-
|
|
124
|
+
// Dynamic ids carry their captured text after a ":" ("shells:4 shells") —
|
|
125
|
+
// strip it so the base def resolves.
|
|
126
|
+
const base = id.includes(":") ? id.slice(0, id.indexOf(":")) : id;
|
|
127
|
+
return defs.find((d) => d.id === base) ?? (base === TYPING_BADGE.id ? TYPING_BADGE : undefined);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Rendered chip text for a (possibly dynamic) badge id: resolves the def and
|
|
132
|
+
* substitutes `$1` in its label with the id's captured text. Unknown ids fall
|
|
133
|
+
* back to the raw id so a newer server's badge never renders blank.
|
|
134
|
+
*/
|
|
135
|
+
export function badgeLabel(id: string, defs: BadgeDef[] = BADGE_DEFS): string {
|
|
136
|
+
const def = badgeDef(id, defs);
|
|
137
|
+
if (!def) return id;
|
|
138
|
+
const arg = id.includes(":") ? id.slice(id.indexOf(":") + 1) : "";
|
|
139
|
+
return def.label.includes("$1") ? def.label.replace("$1", arg) : def.label;
|
|
76
140
|
}
|
package/ts/notifyDaemon.spec.ts
CHANGED
|
@@ -91,10 +91,7 @@ describe("notifyd singleton lock", () => {
|
|
|
91
91
|
expect(await acquireDaemonLock(() => true)).toBe(true);
|
|
92
92
|
const owner = JSON.parse(await readFile(daemonLockOwnerPath(), "utf8"));
|
|
93
93
|
// Rewrite the owner to a DIFFERENT, "alive" pid to simulate another daemon.
|
|
94
|
-
await writeFile(
|
|
95
|
-
daemonLockOwnerPath(),
|
|
96
|
-
JSON.stringify({ ...owner, pid: owner.pid + 1 }),
|
|
97
|
-
);
|
|
94
|
+
await writeFile(daemonLockOwnerPath(), JSON.stringify({ ...owner, pid: owner.pid + 1 }));
|
|
98
95
|
expect(await acquireDaemonLock((pid) => pid === owner.pid + 1)).toBe(false);
|
|
99
96
|
});
|
|
100
97
|
});
|
|
@@ -139,10 +136,7 @@ describe("notifyd identity (status/stop safety)", () => {
|
|
|
139
136
|
it("returns null for an INCOMPLETE owner missing started_at (I1)", async () => {
|
|
140
137
|
await mkdir(daemonLockDir(), { recursive: true });
|
|
141
138
|
// pid alive + fresh ts, but no started_at → identity incomplete, not trusted.
|
|
142
|
-
await writeFile(
|
|
143
|
-
daemonLockOwnerPath(),
|
|
144
|
-
JSON.stringify({ pid: process.pid, ts: Date.now() }),
|
|
145
|
-
);
|
|
139
|
+
await writeFile(daemonLockOwnerPath(), JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
146
140
|
expect(await daemonStatus()).toBeNull();
|
|
147
141
|
});
|
|
148
142
|
|
package/ts/notifyDaemon.ts
CHANGED
|
@@ -18,17 +18,8 @@
|
|
|
18
18
|
import { execFile } from "node:child_process";
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
20
20
|
import { mkdir, readFile, rename, rm, stat, writeFile } from "fs/promises";
|
|
21
|
-
import {
|
|
22
|
-
|
|
23
|
-
type RouterState,
|
|
24
|
-
stepRouter,
|
|
25
|
-
} from "./notifyRouter.ts";
|
|
26
|
-
import {
|
|
27
|
-
type NotifyEvent,
|
|
28
|
-
daemonLockDir,
|
|
29
|
-
daemonLockOwnerPath,
|
|
30
|
-
notifyDir,
|
|
31
|
-
} from "./notifyInbox.ts";
|
|
21
|
+
import { type ChildObservation, type RouterState, stepRouter } from "./notifyRouter.ts";
|
|
22
|
+
import { type NotifyEvent, daemonLockDir, daemonLockOwnerPath, notifyDir } from "./notifyInbox.ts";
|
|
32
23
|
import {
|
|
33
24
|
appendEvent,
|
|
34
25
|
gcInboxes,
|
|
@@ -303,7 +294,12 @@ async function writeOwner(): Promise<boolean> {
|
|
|
303
294
|
try {
|
|
304
295
|
await writeFile(
|
|
305
296
|
tmp,
|
|
306
|
-
JSON.stringify({
|
|
297
|
+
JSON.stringify({
|
|
298
|
+
pid: process.pid,
|
|
299
|
+
started_at: daemonStartedAt,
|
|
300
|
+
ts: Date.now(),
|
|
301
|
+
token: daemonToken,
|
|
302
|
+
}),
|
|
307
303
|
);
|
|
308
304
|
await rename(tmp, daemonLockOwnerPath());
|
|
309
305
|
return true;
|
|
@@ -321,7 +317,9 @@ async function writeOwner(): Promise<boolean> {
|
|
|
321
317
|
* mkdir→writeOwner window of another daemon coming up), so two concurrent starts
|
|
322
318
|
* can't both "win". The liveness predicate is injectable for tests.
|
|
323
319
|
*/
|
|
324
|
-
export async function acquireDaemonLock(
|
|
320
|
+
export async function acquireDaemonLock(
|
|
321
|
+
isAlive: (pid: number) => boolean = isPidAlive,
|
|
322
|
+
): Promise<boolean> {
|
|
325
323
|
// The lock dir lives under notify/ — ensure that exists first, else mkdir of
|
|
326
324
|
// the lock throws ENOENT which must NOT be mistaken for "someone holds it".
|
|
327
325
|
await mkdir(notifyDir(), { recursive: true }).catch(() => {});
|
|
@@ -414,20 +412,23 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<number> {
|
|
|
414
412
|
// tick — many watched children, slow log I/O — can't let the heartbeat cross
|
|
415
413
|
// OWNER_TTL and have another `watch` steal the lock as "stale" → double daemon.
|
|
416
414
|
// Refresh well inside the TTL; cleared on shutdown.
|
|
417
|
-
const ownerBeat = setInterval(
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
415
|
+
const ownerBeat = setInterval(
|
|
416
|
+
() => {
|
|
417
|
+
void (async () => {
|
|
418
|
+
// Refresh ONLY while the owner still carries OUR token. Any other value — a
|
|
419
|
+
// different token (superseded) OR null/absent (a new daemon's mkdir→write
|
|
420
|
+
// window) — means we no longer own it, so stop rather than clobber an
|
|
421
|
+
// unknown/absent owner. Atomic writeOwner means a null read is never our own
|
|
422
|
+
// write in flight.
|
|
423
|
+
if ((await readDaemonOwnerToken()) !== daemonToken) {
|
|
424
|
+
clearInterval(ownerBeat);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
await writeOwner();
|
|
428
|
+
})();
|
|
429
|
+
},
|
|
430
|
+
Math.max(1000, Math.floor(OWNER_TTL_MS / 3)),
|
|
431
|
+
);
|
|
431
432
|
if (typeof ownerBeat.unref === "function") ownerBeat.unref();
|
|
432
433
|
const cleanup = async () => {
|
|
433
434
|
running = false;
|
|
@@ -443,11 +444,7 @@ export async function runDaemon(opts: DaemonOptions = {}): Promise<number> {
|
|
|
443
444
|
// Seed the router from prior inbox state — scoped to currently-watched parents
|
|
444
445
|
// and guarded against pid reuse by the current registry snapshot.
|
|
445
446
|
const watchedAtStart = new Set((await liveWatchers()).keys());
|
|
446
|
-
let prev = await reconcileFromInboxes(
|
|
447
|
-
host,
|
|
448
|
-
await liveChildrenSnapshot(),
|
|
449
|
-
watchedAtStart,
|
|
450
|
-
);
|
|
447
|
+
let prev = await reconcileFromInboxes(host, await liveChildrenSnapshot(), watchedAtStart);
|
|
451
448
|
let ticks = 0;
|
|
452
449
|
let emptySince: number | null = null;
|
|
453
450
|
// Durable across ticks: edges whose append failed, retried until they land.
|
|
@@ -556,7 +553,9 @@ async function tickState(
|
|
|
556
553
|
pendingRetry.set(retryKey(stored), stored);
|
|
557
554
|
logger.warn(`[notifyd] append failed for parent ${stored.parent_pid} — queued for retry`);
|
|
558
555
|
} else {
|
|
559
|
-
logger.warn(
|
|
556
|
+
logger.warn(
|
|
557
|
+
`[notifyd] retry queue full (${RETRY_CAP}) — dropping edge for parent ${stored.parent_pid}`,
|
|
558
|
+
);
|
|
560
559
|
}
|
|
561
560
|
}
|
|
562
561
|
return { next, watcherCount };
|
package/ts/notifyInbox.spec.ts
CHANGED
|
@@ -58,7 +58,8 @@ describe("notifyInbox — NDJSON round-trip + torn-line tolerance", () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
it("skips a torn final line from a mid-append writer", () => {
|
|
61
|
-
const text =
|
|
61
|
+
const text =
|
|
62
|
+
serializeEvent(ev({ seq: 1 })) + "\n" + serializeEvent(ev({ seq: 2 })) + '\n{ "seq": 3, "ed';
|
|
62
63
|
const got = parseInboxText(text);
|
|
63
64
|
expect(got.map((e) => e.seq)).toEqual([1, 2]);
|
|
64
65
|
});
|
package/ts/notifyRouter.spec.ts
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { classifyNeedsInput } from "./needsInput.ts";
|
|
3
|
-
import {
|
|
4
|
-
type ChildObservation,
|
|
5
|
-
type RouterState,
|
|
6
|
-
stepRouter,
|
|
7
|
-
} from "./notifyRouter.ts";
|
|
3
|
+
import { type ChildObservation, type RouterState, stepRouter } from "./notifyRouter.ts";
|
|
8
4
|
|
|
9
5
|
// Mirror the claude `needsInput`/`working` config (rs/default.config.yaml): the
|
|
10
6
|
// menu cursor sits on a NUMBERED option, and a spinner marker means "working".
|
|
@@ -75,7 +71,11 @@ describe("notifyRouter — idle hysteresis (P1)", () => {
|
|
|
75
71
|
|
|
76
72
|
describe("notifyRouter — needs_input", () => {
|
|
77
73
|
it("emits immediately on entering needs_input, with the question", () => {
|
|
78
|
-
const r = step(
|
|
74
|
+
const r = step(
|
|
75
|
+
new Map(),
|
|
76
|
+
[child({ state: "needs_input", question: "Approve? 1.Yes 2.No" })],
|
|
77
|
+
0,
|
|
78
|
+
);
|
|
79
79
|
expect(r.events).toHaveLength(1);
|
|
80
80
|
expect(r.events[0]!.edge).toBe("needs_input");
|
|
81
81
|
expect(r.events[0]!.question).toBe("Approve? 1.Yes 2.No");
|
|
@@ -167,11 +167,7 @@ describe("notifyRouter — idle-prompt fixture (P1 regression)", () => {
|
|
|
167
167
|
});
|
|
168
168
|
|
|
169
169
|
it("an ACTIVE menu (cursor on a number) still classifies as needs_input", () => {
|
|
170
|
-
const activeMenu = [
|
|
171
|
-
" Do you want to proceed?",
|
|
172
|
-
"❯ 1. Yes",
|
|
173
|
-
" 2. No, keep planning",
|
|
174
|
-
];
|
|
170
|
+
const activeMenu = [" Do you want to proceed?", "❯ 1. Yes", " 2. No, keep planning"];
|
|
175
171
|
expect(classifyNeedsInput(activeMenu, CFG)).not.toBeNull();
|
|
176
172
|
});
|
|
177
173
|
|
package/ts/notifyStore.spec.ts
CHANGED
|
@@ -176,7 +176,10 @@ describe("notifyStore (fs)", () => {
|
|
|
176
176
|
await mkdir(inboxDir(host), { recursive: true });
|
|
177
177
|
// Simulate a crash between "reserve seq 5" and "append line": the counter
|
|
178
178
|
// says 5, but the inbox only has 1..3 (4 and 5 are reserved gaps).
|
|
179
|
-
await writeFile(
|
|
179
|
+
await writeFile(
|
|
180
|
+
inboxPath(host, 700),
|
|
181
|
+
[1, 2, 3].map((s) => JSON.stringify(baseEvent("idle", { seq: s }))).join("\n") + "\n",
|
|
182
|
+
);
|
|
180
183
|
await writeFile(seqPath(host, 700), "5");
|
|
181
184
|
const next = await appendEvent(700, baseEvent("idle"));
|
|
182
185
|
expect(next).toBe(6); // continues past the reserved gap, never reuses 4 or 5
|
package/ts/notifyStore.ts
CHANGED
|
@@ -138,10 +138,7 @@ async function atomicWrite(file: string, data: string): Promise<boolean> {
|
|
|
138
138
|
* deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
|
|
139
139
|
* live holder is inviolable.
|
|
140
140
|
*/
|
|
141
|
-
export async function acquireLock(
|
|
142
|
-
lockDir: string,
|
|
143
|
-
staleMs = 30_000,
|
|
144
|
-
): Promise<() => Promise<void>> {
|
|
141
|
+
export async function acquireLock(lockDir: string, staleMs = 30_000): Promise<() => Promise<void>> {
|
|
145
142
|
const ownerFile = path.join(lockDir, "owner");
|
|
146
143
|
// A fencing token unique to THIS acquisition. Everything we do to the lock is
|
|
147
144
|
// gated on the owner file still carrying our token — so if we were stale-stolen
|
|
@@ -194,15 +191,18 @@ export async function acquireLock(
|
|
|
194
191
|
// window) — means we no longer own it, so we stop rather than clobber an
|
|
195
192
|
// unknown/absent owner. Atomic writes above mean a null read is never just
|
|
196
193
|
// our own write in flight. Refresh well inside staleMs; cleared on release.
|
|
197
|
-
const beat = setInterval(
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
194
|
+
const beat = setInterval(
|
|
195
|
+
() => {
|
|
196
|
+
void (async () => {
|
|
197
|
+
if ((await readOwnerToken()) !== token) {
|
|
198
|
+
clearInterval(beat);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await stampOwner();
|
|
202
|
+
})();
|
|
203
|
+
},
|
|
204
|
+
Math.max(500, Math.floor(staleMs / 3)),
|
|
205
|
+
);
|
|
206
206
|
if (typeof beat.unref === "function") beat.unref();
|
|
207
207
|
return async () => {
|
|
208
208
|
clearInterval(beat);
|
|
@@ -296,7 +296,11 @@ export async function listInboxParents(host: string): Promise<number[]> {
|
|
|
296
296
|
return pids;
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
export async function getCursor(
|
|
299
|
+
export async function getCursor(
|
|
300
|
+
host: string,
|
|
301
|
+
parentPid: number,
|
|
302
|
+
consumer = "parent",
|
|
303
|
+
): Promise<number> {
|
|
300
304
|
const text = await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null);
|
|
301
305
|
return parseCursor(text).seq;
|
|
302
306
|
}
|
package/ts/serve.spec.ts
CHANGED
|
@@ -45,9 +45,9 @@ describe("isNoNodeExecError", () => {
|
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
it("matches Windows cmd output", () => {
|
|
48
|
-
expect(
|
|
49
|
-
|
|
50
|
-
)
|
|
48
|
+
expect(isNoNodeExecError("'node' is not recognized as an internal or external command")).toBe(
|
|
49
|
+
true,
|
|
50
|
+
);
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
it("matches localized-then-normalized output (probe pins LC_ALL=C)", () => {
|
|
@@ -58,9 +58,7 @@ describe("isNoNodeExecError", () => {
|
|
|
58
58
|
|
|
59
59
|
it("does not match a real native/glibc failure", () => {
|
|
60
60
|
expect(
|
|
61
|
-
isNoNodeExecError(
|
|
62
|
-
"oxmgr: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found",
|
|
63
|
-
),
|
|
61
|
+
isNoNodeExecError("oxmgr: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.39' not found"),
|
|
64
62
|
).toBe(false);
|
|
65
63
|
expect(isNoNodeExecError("")).toBe(false);
|
|
66
64
|
});
|
package/ts/serve.ts
CHANGED
|
@@ -3,7 +3,18 @@ import { existsSync, renameSync, watch, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { execFileSync } from "node:child_process";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createHash, randomBytes, timingSafeEqual } from "crypto";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
arch,
|
|
8
|
+
cpus,
|
|
9
|
+
freemem,
|
|
10
|
+
homedir,
|
|
11
|
+
hostname,
|
|
12
|
+
loadavg,
|
|
13
|
+
platform,
|
|
14
|
+
totalmem,
|
|
15
|
+
uptime,
|
|
16
|
+
userInfo,
|
|
17
|
+
} from "os";
|
|
7
18
|
import path from "path";
|
|
8
19
|
import yargs from "yargs";
|
|
9
20
|
import {
|
|
@@ -400,9 +411,7 @@ function resolveDaemonManager(): DaemonManager | null {
|
|
|
400
411
|
// - npm: `npm prefix -g` prints the prefix; the bin lives at <prefix>/bin.
|
|
401
412
|
async function globalBinDir(installer: string[]): Promise<string | null> {
|
|
402
413
|
const isBun = installer[1] === "add";
|
|
403
|
-
const query = isBun
|
|
404
|
-
? [installer[0]!, "pm", "bin", "-g"]
|
|
405
|
-
: [installer[0]!, "prefix", "-g"];
|
|
414
|
+
const query = isBun ? [installer[0]!, "pm", "bin", "-g"] : [installer[0]!, "prefix", "-g"];
|
|
406
415
|
const p = Bun.spawn(query, { stdout: "pipe", stderr: "ignore" });
|
|
407
416
|
if ((await p.exited) !== 0) return null;
|
|
408
417
|
const out = (await new Response(p.stdout).text()).trim();
|
|
@@ -1292,7 +1301,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1292
1301
|
// by the console's HTML escaper.
|
|
1293
1302
|
if (title) {
|
|
1294
1303
|
// eslint-disable-next-line no-control-regex
|
|
1295
|
-
title =
|
|
1304
|
+
title =
|
|
1305
|
+
title
|
|
1306
|
+
.replace(/[\x00-\x1f\x7f-\x9f]/g, "")
|
|
1307
|
+
.slice(0, 256)
|
|
1308
|
+
.trim() || null;
|
|
1296
1309
|
}
|
|
1297
1310
|
titleCache.set(logFile, { size, mtimeMs, title });
|
|
1298
1311
|
return title;
|
|
@@ -1311,7 +1324,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1311
1324
|
// the SOURCE: secret-shaped lines are dropped to "···" and long credential-ish
|
|
1312
1325
|
// blobs masked — the feed is token-gated, but tails travel to other rgui hosts.
|
|
1313
1326
|
const previewCache = new Map<string, { size: number; mtimeMs: number; lines: string[] | null }>();
|
|
1314
|
-
const SECRETISH =
|
|
1327
|
+
const SECRETISH =
|
|
1328
|
+
/(api[-_]?key|secret|token|password|passwd|bearer|authorization|private[-_]?key)\s*[=:]/i;
|
|
1315
1329
|
const logPreviewLines = async (logFile: string | null | undefined): Promise<string[] | null> => {
|
|
1316
1330
|
if (!logFile) return null;
|
|
1317
1331
|
try {
|
|
@@ -1860,7 +1874,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1860
1874
|
category: "agent-yes",
|
|
1861
1875
|
owner: `agent-yes:${hostname()}`,
|
|
1862
1876
|
status: r.status,
|
|
1863
|
-
parent:
|
|
1877
|
+
parent:
|
|
1878
|
+
r.parent_pid && byWrapper.has(r.parent_pid)
|
|
1879
|
+
? nid(byWrapper.get(r.parent_pid)!)
|
|
1880
|
+
: undefined,
|
|
1864
1881
|
pos: { x: (i % 8) * (NODE_W + 64), y: Math.floor(i / 8) * 480 },
|
|
1865
1882
|
size: await sizeOf(r.pid),
|
|
1866
1883
|
inputs: [textIn, envIn],
|
|
@@ -1887,7 +1904,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1887
1904
|
configPublic: {
|
|
1888
1905
|
scope: "native-device",
|
|
1889
1906
|
runtime: "native",
|
|
1890
|
-
caps: {
|
|
1907
|
+
caps: {
|
|
1908
|
+
send: true,
|
|
1909
|
+
kill: true,
|
|
1910
|
+
spawn: true,
|
|
1911
|
+
spawnHook: hasSpawnHook(),
|
|
1912
|
+
provision: hasProvisionHook(),
|
|
1913
|
+
},
|
|
1891
1914
|
},
|
|
1892
1915
|
} as unknown as (typeof nodes)[number]); // configPublic is envelope-legal but absent from the agent-node literal type
|
|
1893
1916
|
const codex = records
|
|
@@ -1936,11 +1959,19 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
1936
1959
|
{
|
|
1937
1960
|
kind: "rgui-federated-graph",
|
|
1938
1961
|
schema: "org.rgui.graph.v1",
|
|
1939
|
-
producer: {
|
|
1962
|
+
producer: {
|
|
1963
|
+
app: "ay",
|
|
1964
|
+
origin,
|
|
1965
|
+
deviceId: hostname(),
|
|
1966
|
+
label: `agent-yes fleet @ ${hostname()}`,
|
|
1967
|
+
},
|
|
1940
1968
|
revision: Date.now(),
|
|
1941
1969
|
ts: Date.now(),
|
|
1942
1970
|
graph: { nodes, edges },
|
|
1943
|
-
capabilities: {
|
|
1971
|
+
capabilities: {
|
|
1972
|
+
nodeTypes: [...new Set(nodes.map((n) => n.type))],
|
|
1973
|
+
portTypes: ["text", "environment"],
|
|
1974
|
+
},
|
|
1944
1975
|
},
|
|
1945
1976
|
{ headers: { "Access-Control-Allow-Origin": "*" } },
|
|
1946
1977
|
);
|
|
@@ -2205,7 +2236,12 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2205
2236
|
// verify before demoting: give it 100ms to fire, and only
|
|
2206
2237
|
// then demote this stream to the fast poll it would have used
|
|
2207
2238
|
// had watch() failed outright.
|
|
2208
|
-
if (
|
|
2239
|
+
if (
|
|
2240
|
+
viaPoll &&
|
|
2241
|
+
watcher &&
|
|
2242
|
+
!demoteCheck &&
|
|
2243
|
+
Date.now() - lastWatcherFireAt > POLL_WATCHED - 50
|
|
2244
|
+
) {
|
|
2209
2245
|
const suspectAt = Date.now();
|
|
2210
2246
|
demoteCheck = setTimeout(() => {
|
|
2211
2247
|
demoteCheck = null;
|
|
@@ -2673,7 +2709,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2673
2709
|
// Allowlist gate — only when a provision hook did NOT already gate this
|
|
2674
2710
|
// (a configured hook overrides the allowlist). The fork runs setup-repo.sh
|
|
2675
2711
|
// (code exec), the same risk surface as `from`.
|
|
2676
|
-
if (
|
|
2712
|
+
if (
|
|
2713
|
+
!forkHook.ran &&
|
|
2714
|
+
result.spec &&
|
|
2715
|
+
!isProvisionAllowed(result.spec.owner, result.spec.repo)
|
|
2716
|
+
)
|
|
2677
2717
|
return new Response(
|
|
2678
2718
|
`forking '${result.spec.owner}/${result.spec.repo}' is not allowed — add the owner ` +
|
|
2679
2719
|
`to provisionAllowlist in ~/.agent-yes/config.json (or "*" to allow all), ` +
|
|
@@ -2912,7 +2952,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2912
2952
|
return Response.json(share);
|
|
2913
2953
|
} catch (e) {
|
|
2914
2954
|
const msg = (e as Error).message;
|
|
2915
|
-
const status = /too many active shares/.test(msg)
|
|
2955
|
+
const status = /too many active shares/.test(msg)
|
|
2956
|
+
? 409
|
|
2957
|
+
: /no agent matched|no stable/.test(msg)
|
|
2958
|
+
? 404
|
|
2959
|
+
: 500;
|
|
2916
2960
|
return new Response(msg, { status });
|
|
2917
2961
|
}
|
|
2918
2962
|
}
|
|
@@ -2947,7 +2991,13 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
2947
2991
|
try {
|
|
2948
2992
|
const { ensureExposure } = await import("./expose.ts");
|
|
2949
2993
|
const h = await ensureExposure(port, body.relay);
|
|
2950
|
-
return Response.json({
|
|
2994
|
+
return Response.json({
|
|
2995
|
+
id: h.id,
|
|
2996
|
+
port: h.port,
|
|
2997
|
+
url: h.url,
|
|
2998
|
+
claim: h.mintClaim(),
|
|
2999
|
+
createdAt: h.createdAt,
|
|
3000
|
+
});
|
|
2951
3001
|
} catch (e) {
|
|
2952
3002
|
return new Response(`expose failed: ${(e as Error).message}`, { status: 502 });
|
|
2953
3003
|
}
|
|
@@ -3000,7 +3050,10 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3000
3050
|
const RGUI_CSP = CONSOLE_CSP.replace("frame-ancestors 'none'", "frame-ancestors *")
|
|
3001
3051
|
// #feed= mirrors fetch OTHER rgui hosts' envelopes (federation consumers):
|
|
3002
3052
|
// otoji.org in prod, localhost/loopback for wrangler-dev feeds
|
|
3003
|
-
.replace(
|
|
3053
|
+
.replace(
|
|
3054
|
+
"connect-src 'self'",
|
|
3055
|
+
"connect-src 'self' https://otoji.org http://127.0.0.1:* http://localhost:*",
|
|
3056
|
+
);
|
|
3004
3057
|
const serveUiFile = async (name: string, type: string, csp = CONSOLE_CSP): Promise<Response> => {
|
|
3005
3058
|
try {
|
|
3006
3059
|
const buf = await readFile(path.join(uiDir, name));
|
|
@@ -3046,8 +3099,7 @@ export async function cmdServe(rest: string[]): Promise<number> {
|
|
|
3046
3099
|
return serveUiFile("sw.js", "text/javascript; charset=utf-8");
|
|
3047
3100
|
if (req.method === "GET" && p === "/manifest.webmanifest")
|
|
3048
3101
|
return serveUiFile("manifest.webmanifest", "application/manifest+json");
|
|
3049
|
-
if (req.method === "GET" && p === "/icon.svg")
|
|
3050
|
-
return serveUiFile("icon.svg", "image/svg+xml");
|
|
3102
|
+
if (req.method === "GET" && p === "/icon.svg") return serveUiFile("icon.svg", "image/svg+xml");
|
|
3051
3103
|
if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
|
|
3052
3104
|
return apiFetch(req);
|
|
3053
3105
|
};
|