agent-yes 1.169.0 → 1.170.1
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-D1WUoZA9.js → SUPPORTED_CLIS-ByppVvWO.js} +2 -2
- package/dist/SUPPORTED_CLIS-C_c097RZ.js +8 -0
- package/dist/{agent-yes.config-Bl08kMpl.js → agent-yes.config-ugPYTPOd.js} +2 -2
- package/dist/{agentYesHome-_eJa3DaX.js → agentYesHome-CtHb5b71.js} +1 -1
- package/dist/cli.js +10 -212
- package/dist/{configShared-CEyhl0WH.js → configShared-0MnIQ652.js} +1 -1
- package/dist/{e2e-BeKjLhmO.js → e2e-jb0Hp43q.js} +1 -1
- package/dist/{forkNested-UCKPEgSI.js → forkNested-DhJxa4q4.js} +2 -2
- package/dist/{globalPidIndex-DlmmJlO8.js → globalPidIndex-CoNr7tS8.js} +1 -1
- package/dist/index.js +4 -4
- package/dist/notifyDaemon-D5jtpCAR.js +591 -0
- package/dist/{openBrowser-CCF1iuQK.js → openBrowser-ChR4llYa.js} +1 -1
- package/dist/{pidStore-BfoBWUjc.js → pidStore-BIvsBQ8X.js} +3 -3
- package/dist/pidStore-CLxwp0qI.js +5 -0
- package/dist/{reaper-C-eWAxIj.js → reaper-CWF2_ATd.js} +27 -3
- package/dist/{reaper-BQgDb85W.js → reaper-jfSmog15.js} +1 -1
- package/dist/{remotes-cx_GDFPj.js → remotes-BQMr4_En.js} +3 -3
- package/dist/{remotes-BmAPylU_.js → remotes-CC-4GuJb.js} +3 -3
- package/dist/{runningLock-BobVW1_A.js → runningLock-CNMl13dC.js} +1 -1
- package/dist/rustBinary-DsO0IlNq.js +228 -0
- package/dist/{schedule-DTHFMzDj.js → schedule-ChTPIcXf.js} +7 -7
- package/dist/{serve-D-joIHrL.js → serve-De1gAWNI.js} +120 -26
- package/dist/{setup-K6VTmirE.js → setup-nlMqyEPB.js} +3 -3
- package/dist/{share-D_e2Fwiy.js → share-QregR8a_.js} +2 -2
- package/dist/spawnGate-BFhva-2F.js +5 -0
- package/dist/{spawnGate-DzPfa1PZ.js → spawnGate-IJDByl2U.js} +3 -3
- package/dist/{subcommands-BZPW0UVn.js → subcommands-BFlpV23b.js} +1049 -44
- package/dist/subcommands-DLDpwrfG.js +9 -0
- package/dist/{tray-CWUpaZF4.js → tray-DXr7iK3E.js} +2 -2
- package/dist/{ts-DeahYDN3.js → ts-DsxejviJ.js} +10 -10
- package/dist/{versionChecker-C7KP-aoh.js → versionChecker-Czk3aHqy.js} +2 -2
- package/dist/{webrtcLink-BG0Xc4-W.js → webrtcLink-B7REGtK2.js} +2 -2
- package/dist/{webrtcRemote-SybKurg9.js → webrtcRemote-Bx-eD_0I.js} +3 -3
- package/dist/{workspaceConfig-BC03X4Y1.js → workspaceConfig-BgqK-31W.js} +37 -6
- package/lab/ui/console-logic.js +79 -10
- package/lab/ui/index.html +174 -8
- package/lab/ui/landing.html +11 -1
- package/package.json +1 -1
- package/scripts/build-rs.ts +9 -5
- package/ts/badges.spec.ts +51 -1
- package/ts/badges.ts +35 -1
- package/ts/index.ts +13 -4
- package/ts/notifyDaemon.spec.ts +276 -0
- package/ts/notifyDaemon.ts +698 -0
- package/ts/notifyInbox.spec.ts +192 -0
- package/ts/notifyInbox.ts +294 -0
- package/ts/notifyRouter.spec.ts +355 -0
- package/ts/notifyRouter.ts +288 -0
- package/ts/notifyStore.spec.ts +308 -0
- package/ts/notifyStore.ts +439 -0
- package/ts/reaper.spec.ts +18 -1
- package/ts/reaper.ts +33 -1
- package/ts/rustBinary.ts +30 -0
- package/ts/serve.ts +180 -14
- package/ts/subcommands.spec.ts +341 -0
- package/ts/subcommands.ts +764 -29
- package/ts/workspaceConfig.spec.ts +48 -0
- package/ts/workspaceConfig.ts +46 -3
- package/dist/SUPPORTED_CLIS-BFV10E3f.js +0 -8
- package/dist/pidStore-5qyLX3Fh.js +0 -5
- package/dist/spawnGate-CQ1Il3Xk.js +0 -5
- package/dist/subcommands-DuGwtmz5.js +0 -9
|
@@ -1,15 +1,485 @@
|
|
|
1
1
|
import { t as invokedCliName } from "./invokedCli-uqM2YYA7.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
2
|
+
import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
3
|
+
import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
|
|
4
|
+
import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-CoNr7tS8.js";
|
|
5
|
+
import { t as loadSharedCliDefaults } from "./configShared-0MnIQ652.js";
|
|
6
|
+
import { n as isWebrtcSpec } from "./webrtcLink-B7REGtK2.js";
|
|
7
|
+
import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-CC-4GuJb.js";
|
|
7
8
|
import ms from "ms";
|
|
8
9
|
import yargs from "yargs";
|
|
9
|
-
import { appendFile, mkdir, open, readFile, stat, writeFile } from "fs/promises";
|
|
10
|
+
import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
|
|
10
11
|
import { homedir } from "os";
|
|
11
12
|
import path from "path";
|
|
13
|
+
import { randomUUID } from "node:crypto";
|
|
14
|
+
import os from "node:os";
|
|
12
15
|
|
|
16
|
+
//#region ts/notifyInbox.ts
|
|
17
|
+
/**
|
|
18
|
+
* Pure, fs-free core for the subagent→parent notification inbox — the storage
|
|
19
|
+
* and watermark layer under `ay notify` / `ay notifyd`.
|
|
20
|
+
*
|
|
21
|
+
* Motivation (real incident): a parent agent fanned out sub-agents that finished
|
|
22
|
+
* their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
|
|
23
|
+
* exit on idle). Claude Code's background-task notification only fires on process
|
|
24
|
+
* EXIT, so the parent never learned the children went idle and left them parked
|
|
25
|
+
* 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
|
|
26
|
+
* parent must run the watch loop. The whole point of this pain is the parent is
|
|
27
|
+
* NOT watching. So we accumulate qualifying edges into a per-parent append-only
|
|
28
|
+
* inbox that the parent drains on its own schedule (its Monitor loop), with a
|
|
29
|
+
* persisted cursor so a parent that restarts reads only the unread edges.
|
|
30
|
+
*
|
|
31
|
+
* This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
|
|
32
|
+
* filtering, retention decisions) so it is trivially unit-testable, mirroring
|
|
33
|
+
* `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
|
|
34
|
+
* + CLI live in `subcommands.ts`; the detection/debounce lives in
|
|
35
|
+
* `notifyRouter.ts`.
|
|
36
|
+
*/
|
|
37
|
+
/** Root dir for all notification state. */
|
|
38
|
+
function notifyDir() {
|
|
39
|
+
return path.join(agentYesHome(), "notify");
|
|
40
|
+
}
|
|
41
|
+
/** Per-host inbox directory (pids are only unique within a host). */
|
|
42
|
+
function inboxDir(host) {
|
|
43
|
+
return path.join(notifyDir(), "inbox", sanitizeHost(host));
|
|
44
|
+
}
|
|
45
|
+
/** Absolute path of one parent's append-only NDJSON inbox. */
|
|
46
|
+
function inboxPath(host, parentPid) {
|
|
47
|
+
return path.join(inboxDir(host), `${parentPid}.ndjson`);
|
|
48
|
+
}
|
|
49
|
+
/** Sidecar seq counter for a parent inbox (last allocated seq). */
|
|
50
|
+
function seqPath(host, parentPid) {
|
|
51
|
+
return path.join(inboxDir(host), `${parentPid}.seq`);
|
|
52
|
+
}
|
|
53
|
+
/** Per-consumer cursor directory for a parent inbox. */
|
|
54
|
+
function cursorDir(host, parentPid) {
|
|
55
|
+
return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
|
|
56
|
+
}
|
|
57
|
+
/** Absolute path of one consumer's cursor file for a parent inbox. */
|
|
58
|
+
function cursorPath(host, parentPid, consumer = "parent") {
|
|
59
|
+
return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
|
|
60
|
+
}
|
|
61
|
+
/** The daemon singleton lock dir (mkdir-based, like pidStore). */
|
|
62
|
+
function daemonLockDir() {
|
|
63
|
+
return path.join(notifyDir(), "notifyd.lock");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
|
|
67
|
+
* of truth for the running daemon's identity, used for stale-lock steal and for
|
|
68
|
+
* `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
|
|
69
|
+
*/
|
|
70
|
+
function daemonLockOwnerPath() {
|
|
71
|
+
return path.join(daemonLockDir(), "owner.json");
|
|
72
|
+
}
|
|
73
|
+
/** Registry dir of parents currently running `ay notify watch` (heartbeats). */
|
|
74
|
+
function watchersDir() {
|
|
75
|
+
return path.join(notifyDir(), "watchers");
|
|
76
|
+
}
|
|
77
|
+
/** Heartbeat file for one watching parent. */
|
|
78
|
+
function watcherPath(parentPid) {
|
|
79
|
+
return path.join(watchersDir(), `${parentPid}.json`);
|
|
80
|
+
}
|
|
81
|
+
/** A watcher heartbeat is "live" if refreshed within this window. */
|
|
82
|
+
const WATCHER_TTL_MS = 15e3;
|
|
83
|
+
function sanitizeSegment(s, fallback) {
|
|
84
|
+
const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
85
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
|
|
86
|
+
}
|
|
87
|
+
function sanitizeHost(host) {
|
|
88
|
+
return sanitizeSegment(host, "localhost");
|
|
89
|
+
}
|
|
90
|
+
function sanitizeConsumer(consumer) {
|
|
91
|
+
return sanitizeSegment(consumer, "parent");
|
|
92
|
+
}
|
|
93
|
+
/** Serialize one event to its NDJSON line (no trailing newline). */
|
|
94
|
+
function serializeEvent(ev) {
|
|
95
|
+
return JSON.stringify(ev);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Parse an inbox's raw text into events. Tolerant by design: a torn final line
|
|
99
|
+
* (a writer mid-append) or any unparseable line is skipped, never throws — a
|
|
100
|
+
* reader must survive a concurrent writer. Order is file order (= seq order,
|
|
101
|
+
* since a single locked writer appends in increasing seq).
|
|
102
|
+
*/
|
|
103
|
+
function parseInboxText(text) {
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const line of text.split("\n")) {
|
|
106
|
+
const t = line.trim();
|
|
107
|
+
if (!t) continue;
|
|
108
|
+
try {
|
|
109
|
+
const ev = JSON.parse(t);
|
|
110
|
+
if (isValidEvent(ev)) out.push(ev);
|
|
111
|
+
} catch {}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
const EDGES = new Set([
|
|
116
|
+
"needs_input",
|
|
117
|
+
"idle",
|
|
118
|
+
"exited"
|
|
119
|
+
]);
|
|
120
|
+
/**
|
|
121
|
+
* Full shape validation at the storage boundary — a torn/partial line missing
|
|
122
|
+
* the correlation fields must never flow through to a consumer's output. Every
|
|
123
|
+
* event we surface has the fields a reader relies on.
|
|
124
|
+
*/
|
|
125
|
+
function isValidEvent(ev) {
|
|
126
|
+
if (!ev || typeof ev !== "object") return false;
|
|
127
|
+
const e = ev;
|
|
128
|
+
return typeof e.seq === "number" && typeof e.ts === "number" && typeof e.host === "string" && typeof e.parent_pid === "number" && typeof e.child_pid === "number" && typeof e.cli === "string" && typeof e.cwd === "string" && typeof e.edge === "string" && EDGES.has(e.edge) && typeof e.state === "string" && (e.question === null || typeof e.question === "string");
|
|
129
|
+
}
|
|
130
|
+
/** Next seq to allocate given the last stored seq counter (or the inbox max). */
|
|
131
|
+
function nextSeq(lastSeq) {
|
|
132
|
+
return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
|
|
133
|
+
}
|
|
134
|
+
/** Highest seq present in a parsed inbox (0 when empty). */
|
|
135
|
+
function maxSeq(events) {
|
|
136
|
+
let m = 0;
|
|
137
|
+
for (const e of events) if (e.seq > m) m = e.seq;
|
|
138
|
+
return m;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Filter events for `--since <seq>`: strictly-greater than the given seq. A
|
|
142
|
+
* `since` of 0/undefined returns everything.
|
|
143
|
+
*/
|
|
144
|
+
function filterSinceSeq(events, sinceSeq) {
|
|
145
|
+
if (!sinceSeq || sinceSeq <= 0) return events.slice();
|
|
146
|
+
return events.filter((e) => e.seq > sinceSeq);
|
|
147
|
+
}
|
|
148
|
+
/** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
|
|
149
|
+
function filterSinceTs(events, sinceTs) {
|
|
150
|
+
if (!sinceTs || sinceTs <= 0) return events.slice();
|
|
151
|
+
return events.filter((e) => e.ts >= sinceTs);
|
|
152
|
+
}
|
|
153
|
+
/** Unread = seq strictly greater than the consumer's cursor. */
|
|
154
|
+
function filterUnread(events, cursorSeq) {
|
|
155
|
+
return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
|
|
156
|
+
}
|
|
157
|
+
/** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
|
|
158
|
+
function parseCursor(text) {
|
|
159
|
+
if (!text) return { seq: 0 };
|
|
160
|
+
try {
|
|
161
|
+
const c = JSON.parse(text);
|
|
162
|
+
return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
|
|
163
|
+
} catch {
|
|
164
|
+
return { seq: 0 };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function serializeCursor(seq) {
|
|
168
|
+
return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
|
|
172
|
+
* un-acked cursor across ALL consumers, return the events to KEEP after
|
|
173
|
+
* rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
|
|
174
|
+
* that is the at-least-once guarantee: we must not evict an edge no consumer has
|
|
175
|
+
* acknowledged yet, even under the byte cap. Below that watermark we keep the
|
|
176
|
+
* newest events that fit the cap (and at least `minKeep`).
|
|
177
|
+
*/
|
|
178
|
+
function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
|
|
179
|
+
if (events.length <= minKeep) return events.slice();
|
|
180
|
+
const kept = [];
|
|
181
|
+
let bytes = 0;
|
|
182
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
183
|
+
const e = events[i];
|
|
184
|
+
const line = serializeEvent(e) + "\n";
|
|
185
|
+
bytes += line.length;
|
|
186
|
+
if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
|
|
187
|
+
else break;
|
|
188
|
+
}
|
|
189
|
+
kept.reverse();
|
|
190
|
+
return kept;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region ts/notifyStore.ts
|
|
195
|
+
/**
|
|
196
|
+
* Filesystem side of the notification inbox — the impure half of the pure
|
|
197
|
+
* `notifyInbox.ts` path/serialization core. Handles the locked append (so
|
|
198
|
+
* concurrent daemon ticks / future writers never interleave a seq), inbox and
|
|
199
|
+
* cursor reads/writes, and retention GC.
|
|
200
|
+
*
|
|
201
|
+
* The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
|
|
202
|
+
* only for the microseconds of a read-counter → append-line → write-counter, so
|
|
203
|
+
* contention is negligible even with many parents.
|
|
204
|
+
*/
|
|
205
|
+
/** Stable local host id — the pid namespace is per-host. */
|
|
206
|
+
function hostId() {
|
|
207
|
+
return os.hostname() || "localhost";
|
|
208
|
+
}
|
|
209
|
+
function pidAlive(pid) {
|
|
210
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
211
|
+
try {
|
|
212
|
+
process.kill(pid, 0);
|
|
213
|
+
return true;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return e.code === "EPERM";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
|
|
220
|
+
* steal the lock ONLY when its holder is provably gone (torn/missing owner on a
|
|
221
|
+
* lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
|
|
222
|
+
* stale — NEVER merely because the contender has waited a while.
|
|
223
|
+
*
|
|
224
|
+
* Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
|
|
225
|
+
* (`lockAgeMs` = now − lockDir mtime), NOT from how long the contender has been
|
|
226
|
+
* waiting. Otherwise a contender that waited out one holder could instantly steal
|
|
227
|
+
* the NEXT holder's freshly-created lock during its mkdir→write-owner window
|
|
228
|
+
* (torn but brand-new) — letting two writers into the critical section.
|
|
229
|
+
*/
|
|
230
|
+
function shouldStealLock(ownerRaw, now, opts) {
|
|
231
|
+
const graceMs = opts.graceMs ?? 1e3;
|
|
232
|
+
let ownerPid = 0;
|
|
233
|
+
let ownerTs = 0;
|
|
234
|
+
let parsed = false;
|
|
235
|
+
try {
|
|
236
|
+
const o = JSON.parse(ownerRaw);
|
|
237
|
+
ownerPid = o?.pid ?? 0;
|
|
238
|
+
ownerTs = o?.ts ?? 0;
|
|
239
|
+
parsed = true;
|
|
240
|
+
} catch {}
|
|
241
|
+
if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
|
|
242
|
+
const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
|
|
243
|
+
const holderStale = now - ownerTs > opts.staleMs;
|
|
244
|
+
return holderDead || holderStale;
|
|
245
|
+
}
|
|
246
|
+
async function ensureDir(dir) {
|
|
247
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Atomically overwrite a single file (temp + rename) so a concurrent reader never
|
|
251
|
+
* observes a torn/partial write — the sweep invariant for every single-file
|
|
252
|
+
* mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
|
|
253
|
+
*/
|
|
254
|
+
async function atomicWrite(file, data) {
|
|
255
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
256
|
+
try {
|
|
257
|
+
await writeFile(tmp, data);
|
|
258
|
+
await rename(tmp, file);
|
|
259
|
+
return true;
|
|
260
|
+
} catch {
|
|
261
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
|
|
267
|
+
* successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
|
|
268
|
+
* driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
|
|
269
|
+
* `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
|
|
270
|
+
* it holds the lock, so a contender steals ONLY when that holder is dead or its
|
|
271
|
+
* heartbeat is stale. A live holder whose critical section legitimately runs long
|
|
272
|
+
* (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
|
|
273
|
+
* and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
|
|
274
|
+
* deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
|
|
275
|
+
* live holder is inviolable.
|
|
276
|
+
*/
|
|
277
|
+
async function acquireLock(lockDir, staleMs = 3e4) {
|
|
278
|
+
const ownerFile = path.join(lockDir, "owner");
|
|
279
|
+
const token = randomUUID();
|
|
280
|
+
const readOwnerToken = async () => {
|
|
281
|
+
try {
|
|
282
|
+
return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
const stampOwner = async () => {
|
|
288
|
+
const tmp = `${ownerFile}.${token}.tmp`;
|
|
289
|
+
try {
|
|
290
|
+
await writeFile(tmp, JSON.stringify({
|
|
291
|
+
pid: process.pid,
|
|
292
|
+
ts: Date.now(),
|
|
293
|
+
token
|
|
294
|
+
}));
|
|
295
|
+
await rename(tmp, ownerFile);
|
|
296
|
+
return true;
|
|
297
|
+
} catch {
|
|
298
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
const lockAgeMs = async () => {
|
|
303
|
+
const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
|
|
304
|
+
return Date.now() - m;
|
|
305
|
+
};
|
|
306
|
+
for (;;) try {
|
|
307
|
+
await mkdir(lockDir, { recursive: false });
|
|
308
|
+
if (!await stampOwner()) {
|
|
309
|
+
await rm(lockDir, {
|
|
310
|
+
recursive: true,
|
|
311
|
+
force: true
|
|
312
|
+
}).catch(() => {});
|
|
313
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const beat = setInterval(() => {
|
|
317
|
+
(async () => {
|
|
318
|
+
if (await readOwnerToken() !== token) {
|
|
319
|
+
clearInterval(beat);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
await stampOwner();
|
|
323
|
+
})();
|
|
324
|
+
}, Math.max(500, Math.floor(staleMs / 3)));
|
|
325
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
326
|
+
return async () => {
|
|
327
|
+
clearInterval(beat);
|
|
328
|
+
if (await readOwnerToken() === token) await rm(lockDir, {
|
|
329
|
+
recursive: true,
|
|
330
|
+
force: true
|
|
331
|
+
}).catch(() => {});
|
|
332
|
+
};
|
|
333
|
+
} catch (e) {
|
|
334
|
+
if (e.code !== "EEXIST") throw e;
|
|
335
|
+
if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
|
|
336
|
+
staleMs,
|
|
337
|
+
lockAgeMs: await lockAgeMs(),
|
|
338
|
+
selfPid: process.pid,
|
|
339
|
+
isAlive: pidAlive
|
|
340
|
+
})) {
|
|
341
|
+
await rm(lockDir, {
|
|
342
|
+
recursive: true,
|
|
343
|
+
force: true
|
|
344
|
+
}).catch(() => {});
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Append one event to a parent's inbox under lock, allocating the next seq. The
|
|
352
|
+
* caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
|
|
353
|
+
*/
|
|
354
|
+
async function appendEvent(parentPid, ev) {
|
|
355
|
+
const host = ev.host;
|
|
356
|
+
const dir = inboxDir(host);
|
|
357
|
+
await ensureDir(dir);
|
|
358
|
+
const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
|
|
359
|
+
try {
|
|
360
|
+
let last = 0;
|
|
361
|
+
const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
|
|
362
|
+
const parsed = parseInt(counterRaw.trim(), 10);
|
|
363
|
+
if (Number.isFinite(parsed) && parsed > 0) last = parsed;
|
|
364
|
+
else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
|
|
365
|
+
const seq = nextSeq(last);
|
|
366
|
+
const full = {
|
|
367
|
+
...ev,
|
|
368
|
+
seq
|
|
369
|
+
};
|
|
370
|
+
await writeFile(seqPath(host, parentPid), String(seq));
|
|
371
|
+
await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
|
|
372
|
+
return seq;
|
|
373
|
+
} finally {
|
|
374
|
+
await release();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
/** Read + parse a parent's inbox (empty array when none). */
|
|
378
|
+
async function readInbox(host, parentPid) {
|
|
379
|
+
return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
|
|
380
|
+
}
|
|
381
|
+
/** All parent pids that currently have an inbox on this host. */
|
|
382
|
+
async function listInboxParents(host) {
|
|
383
|
+
const names = await readdir(inboxDir(host)).catch(() => []);
|
|
384
|
+
const pids = [];
|
|
385
|
+
for (const n of names) {
|
|
386
|
+
const m = /^(\d+)\.ndjson$/.exec(n);
|
|
387
|
+
if (m) pids.push(parseInt(m[1], 10));
|
|
388
|
+
}
|
|
389
|
+
return pids;
|
|
390
|
+
}
|
|
391
|
+
async function getCursor(host, parentPid, consumer = "parent") {
|
|
392
|
+
return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Minimum cursor across ALL consumers of a parent inbox — the watermark below
|
|
396
|
+
* which rotation may evict events (nothing above it has been acked by every
|
|
397
|
+
* reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
|
|
398
|
+
* with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
|
|
399
|
+
* nothing (an inbox nobody reads is never trimmed).
|
|
400
|
+
*/
|
|
401
|
+
async function minConsumerCursor(host, parentPid) {
|
|
402
|
+
const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
|
|
403
|
+
if (cursors.length === 0) return 0;
|
|
404
|
+
let min = Infinity;
|
|
405
|
+
for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
|
|
406
|
+
return Number.isFinite(min) ? min : 0;
|
|
407
|
+
}
|
|
408
|
+
async function setCursor(host, parentPid, seq, consumer = "parent") {
|
|
409
|
+
const p = cursorPath(host, parentPid, consumer);
|
|
410
|
+
await ensureDir(path.dirname(p));
|
|
411
|
+
await atomicWrite(p, serializeCursor(seq));
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Retention: delete inboxes (+ their seq counter + cursors) for parents that are
|
|
415
|
+
* both dead and unreferenced by any live child. `capBytes` also rotates a live
|
|
416
|
+
* inbox that has grown past the cap, keeping the newest events.
|
|
417
|
+
*/
|
|
418
|
+
async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024) {
|
|
419
|
+
const parents = await listInboxParents(host);
|
|
420
|
+
for (const p of parents) {
|
|
421
|
+
const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
|
|
422
|
+
try {
|
|
423
|
+
if (!livePids.has(p) && !liveChildParentPids.has(p)) {
|
|
424
|
+
await rm(inboxPath(host, p), { force: true }).catch(() => {});
|
|
425
|
+
await rm(seqPath(host, p), { force: true }).catch(() => {});
|
|
426
|
+
await rm(cursorDir(host, p), {
|
|
427
|
+
recursive: true,
|
|
428
|
+
force: true
|
|
429
|
+
}).catch(() => {});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
|
|
433
|
+
if (size <= capBytes) continue;
|
|
434
|
+
const events = await readInbox(host, p);
|
|
435
|
+
const kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
|
|
436
|
+
if (kept.length < events.length) await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
|
|
437
|
+
else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
|
|
438
|
+
} finally {
|
|
439
|
+
await release();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
|
|
444
|
+
* liveWatchers never reads a torn file and transiently drops the watcher). */
|
|
445
|
+
async function heartbeatWatcher(parentPid, startedAt) {
|
|
446
|
+
await ensureDir(watchersDir());
|
|
447
|
+
await atomicWrite(watcherPath(parentPid), JSON.stringify({
|
|
448
|
+
pid: parentPid,
|
|
449
|
+
started_at: startedAt,
|
|
450
|
+
ts: Date.now()
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
/** Remove this parent's watch heartbeat (on watch exit). */
|
|
454
|
+
async function clearWatcher(parentPid) {
|
|
455
|
+
await rm(watcherPath(parentPid), { force: true }).catch(() => {});
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Live watchers as a map of parent pid → the parent's own `started_at` (its
|
|
459
|
+
* authoritative self-reported start time, so the daemon never has to guess it
|
|
460
|
+
* from the registry and stamp a 0). A watcher counts as live ONLY if its
|
|
461
|
+
* heartbeat is fresh AND its process is actually alive — so a crashed `watch`
|
|
462
|
+
* whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
|
|
463
|
+
* parent's inbox (which would violate "nothing happens unless you watch"). A
|
|
464
|
+
* heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
|
|
465
|
+
* daemon's cross-session scope guard keys off a positive parent start time, so a
|
|
466
|
+
* 0 would defeat it — fail closed.
|
|
467
|
+
*/
|
|
468
|
+
async function liveWatchers(now = Date.now()) {
|
|
469
|
+
const names = await readdir(watchersDir()).catch(() => []);
|
|
470
|
+
const live = /* @__PURE__ */ new Map();
|
|
471
|
+
for (const n of names) {
|
|
472
|
+
if (!n.endsWith(".json")) continue;
|
|
473
|
+
const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
|
|
474
|
+
try {
|
|
475
|
+
const o = JSON.parse(raw);
|
|
476
|
+
if (typeof o?.pid === "number" && typeof o?.started_at === "number" && o.started_at > 0 && typeof o?.ts === "number" && now - o.ts <= WATCHER_TTL_MS && pidAlive(o.pid)) live.set(o.pid, o.started_at);
|
|
477
|
+
} catch {}
|
|
478
|
+
}
|
|
479
|
+
return live;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
//#endregion
|
|
13
483
|
//#region ts/agentTree.ts
|
|
14
484
|
/**
|
|
15
485
|
* Link records into a forest via parent_pid === wrapper_pid. Records whose
|
|
@@ -159,12 +629,26 @@ function parseTaskCounts(lines) {
|
|
|
159
629
|
|
|
160
630
|
//#endregion
|
|
161
631
|
//#region ts/badges.ts
|
|
162
|
-
const BADGE_DEFS = [
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
632
|
+
const BADGE_DEFS = [
|
|
633
|
+
{
|
|
634
|
+
id: "goal-active",
|
|
635
|
+
label: "goal",
|
|
636
|
+
title: "A /goal Stop-hook loop is active on this agent",
|
|
637
|
+
pattern: /\/goal active/i
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "session-limit",
|
|
641
|
+
label: "limit",
|
|
642
|
+
title: "Usage session limit hit — waiting for the reset time shown on screen",
|
|
643
|
+
pattern: /you['’]?ve hit your session limit/i
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: "retrying",
|
|
647
|
+
label: "retry",
|
|
648
|
+
title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
|
|
649
|
+
pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i
|
|
650
|
+
}
|
|
651
|
+
];
|
|
168
652
|
/**
|
|
169
653
|
* Which badge ids match the given rendered screen lines. Pure so it's
|
|
170
654
|
* unit-tested without a live PTY/log file.
|
|
@@ -173,6 +657,22 @@ function matchBadges(lines, defs = BADGE_DEFS) {
|
|
|
173
657
|
const text = lines.join("\n");
|
|
174
658
|
return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
|
|
175
659
|
}
|
|
660
|
+
/**
|
|
661
|
+
* A time-derived flag (NOT screen-matched): lit when the user typed at this
|
|
662
|
+
* agent's terminal within the last few seconds. The ls code appends its id from
|
|
663
|
+
* the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
|
|
664
|
+
* like any other chip — but `matchBadges` never produces it (it isn't in
|
|
665
|
+
* BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
|
|
666
|
+
*/
|
|
667
|
+
const TYPING_BADGE = {
|
|
668
|
+
id: "typing",
|
|
669
|
+
label: "typing",
|
|
670
|
+
title: "The user is typing at this agent's terminal — ay send backs off until they pause",
|
|
671
|
+
pattern: /(?!)/
|
|
672
|
+
};
|
|
673
|
+
function badgeDef(id, defs = BADGE_DEFS) {
|
|
674
|
+
return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : void 0);
|
|
675
|
+
}
|
|
176
676
|
|
|
177
677
|
//#endregion
|
|
178
678
|
//#region ts/needsInput.ts
|
|
@@ -549,6 +1049,8 @@ const SUBCOMMANDS = new Set([
|
|
|
549
1049
|
"ps",
|
|
550
1050
|
"status",
|
|
551
1051
|
"result",
|
|
1052
|
+
"notify",
|
|
1053
|
+
"notifyd",
|
|
552
1054
|
"read",
|
|
553
1055
|
"cat",
|
|
554
1056
|
"tail",
|
|
@@ -574,6 +1076,14 @@ const STUCK_THRESHOLD_MS = (() => {
|
|
|
574
1076
|
const n = Number(process.env.AGENT_YES_STUCK_MS);
|
|
575
1077
|
return Number.isFinite(n) && n > 0 ? n : 300 * 1e3;
|
|
576
1078
|
})();
|
|
1079
|
+
const SEND_SETTLE_QUIET_MS = 150;
|
|
1080
|
+
const SEND_SETTLE_MAX_MS = 1500;
|
|
1081
|
+
const SEND_CONFIRM_QUIET_MS = 400;
|
|
1082
|
+
const SEND_CONFIRM_MAX_MS = 1200;
|
|
1083
|
+
const SEND_CONFIRM_MIN_GROWTH_BYTES = 8;
|
|
1084
|
+
const SEND_SUBMIT_MAX_RETRIES = 2;
|
|
1085
|
+
const SEND_TYPING_POLL_MS = 200;
|
|
1086
|
+
const SEND_TYPING_MAX_WAIT_MS = 1e4;
|
|
577
1087
|
/**
|
|
578
1088
|
* Whether `name` is a subcommand. `managerCommands` (default true, for the
|
|
579
1089
|
* generic `ay`/`agent-yes` entry) additionally admits manager-only commands
|
|
@@ -600,6 +1110,8 @@ async function runSubcommand(argv) {
|
|
|
600
1110
|
case "ps": return await cmdLs(rest);
|
|
601
1111
|
case "status": return await cmdStatus(rest);
|
|
602
1112
|
case "result": return await cmdResult(rest);
|
|
1113
|
+
case "notify": return await cmdNotify(rest);
|
|
1114
|
+
case "notifyd": return await cmdNotifyd(rest);
|
|
603
1115
|
case "read":
|
|
604
1116
|
case "cat": return await cmdRead(rest, { mode: "cat" });
|
|
605
1117
|
case "tail": return await cmdRead(rest, { mode: "tail" });
|
|
@@ -614,23 +1126,23 @@ async function runSubcommand(argv) {
|
|
|
614
1126
|
case "restart": return await cmdRestart(rest);
|
|
615
1127
|
case "note": return await cmdNote(rest);
|
|
616
1128
|
case "serve": {
|
|
617
|
-
const { cmdServe } = await import("./serve-
|
|
1129
|
+
const { cmdServe } = await import("./serve-De1gAWNI.js");
|
|
618
1130
|
return cmdServe(rest);
|
|
619
1131
|
}
|
|
620
1132
|
case "setup": {
|
|
621
|
-
const { cmdSetup } = await import("./setup-
|
|
1133
|
+
const { cmdSetup } = await import("./setup-nlMqyEPB.js");
|
|
622
1134
|
return cmdSetup(rest);
|
|
623
1135
|
}
|
|
624
1136
|
case "schedule": {
|
|
625
|
-
const { cmdSchedule } = await import("./schedule-
|
|
1137
|
+
const { cmdSchedule } = await import("./schedule-ChTPIcXf.js");
|
|
626
1138
|
return cmdSchedule(rest);
|
|
627
1139
|
}
|
|
628
1140
|
case "remote": {
|
|
629
|
-
const { cmdRemote } = await import("./remotes-
|
|
1141
|
+
const { cmdRemote } = await import("./remotes-BQMr4_En.js");
|
|
630
1142
|
return cmdRemote(rest);
|
|
631
1143
|
}
|
|
632
1144
|
case "reap":
|
|
633
|
-
await (await import("./reaper-
|
|
1145
|
+
await (await import("./reaper-jfSmog15.js")).sweep();
|
|
634
1146
|
return 0;
|
|
635
1147
|
case "help": return cmdHelp(managerCommands);
|
|
636
1148
|
default: return null;
|
|
@@ -1019,7 +1531,7 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
|
|
|
1019
1531
|
let base = url;
|
|
1020
1532
|
let bearer = token;
|
|
1021
1533
|
if (isWebrtcSpec(url)) {
|
|
1022
|
-
const { startWebrtcBridge } = await import("./webrtcRemote-
|
|
1534
|
+
const { startWebrtcBridge } = await import("./webrtcRemote-Bx-eD_0I.js");
|
|
1023
1535
|
bridge = await startWebrtcBridge(url);
|
|
1024
1536
|
base = bridge.baseUrl;
|
|
1025
1537
|
bearer = bridge.token;
|
|
@@ -1052,41 +1564,85 @@ async function runAllRemotesLs(opts) {
|
|
|
1052
1564
|
host: alias,
|
|
1053
1565
|
records
|
|
1054
1566
|
})))]);
|
|
1055
|
-
const
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1567
|
+
const byHost = [];
|
|
1568
|
+
const gitRootCache = /* @__PURE__ */ new Map();
|
|
1569
|
+
const gitInfoCache = /* @__PURE__ */ new Map();
|
|
1570
|
+
if (localResult.status === "fulfilled") {
|
|
1571
|
+
const enriched = await Promise.all(localResult.value.records.map(async (r) => {
|
|
1572
|
+
const { state, question } = await deriveLiveState(r);
|
|
1573
|
+
const [tasks, badges, git, typing] = state !== "stopped" ? await Promise.all([
|
|
1574
|
+
r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
|
|
1575
|
+
r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
|
|
1576
|
+
gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
|
|
1577
|
+
isUserTyping(r.pid)
|
|
1578
|
+
]) : [
|
|
1579
|
+
null,
|
|
1580
|
+
[],
|
|
1581
|
+
null,
|
|
1582
|
+
false
|
|
1583
|
+
];
|
|
1584
|
+
return {
|
|
1585
|
+
...r,
|
|
1586
|
+
status: state,
|
|
1587
|
+
question,
|
|
1588
|
+
last_active_at: await deriveLastActiveAt(r),
|
|
1589
|
+
tasks,
|
|
1590
|
+
badges: typing ? [...badges, TYPING_BADGE.id] : badges,
|
|
1591
|
+
git
|
|
1592
|
+
};
|
|
1593
|
+
}));
|
|
1594
|
+
byHost.push({
|
|
1595
|
+
host: "local",
|
|
1596
|
+
records: enriched
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
for (const res of remoteResults) if (res.status === "fulfilled") byHost.push({
|
|
1061
1600
|
host: res.value.host,
|
|
1062
|
-
|
|
1601
|
+
records: res.value.records
|
|
1602
|
+
});
|
|
1603
|
+
const rows = [];
|
|
1604
|
+
for (const { host, records } of byHost) for (const { record, prefix } of flattenForest(buildAgentForest(records))) rows.push({
|
|
1605
|
+
host,
|
|
1606
|
+
rec: record,
|
|
1607
|
+
prefix
|
|
1063
1608
|
});
|
|
1064
1609
|
if (rows.length === 0) {
|
|
1065
1610
|
process.stderr.write("no running agents\n");
|
|
1066
1611
|
return 0;
|
|
1067
1612
|
}
|
|
1068
1613
|
const termWidth = process.stdout.columns ?? 120;
|
|
1614
|
+
const now = Date.now();
|
|
1615
|
+
const ageOf = (rec) => humanizeAge(now - (rec.last_active_at ?? rec.started_at));
|
|
1616
|
+
const badgeOf = (rec) => rec.tasks ? `${rec.tasks.done}/${rec.tasks.total} ` : "";
|
|
1069
1617
|
const hostW = Math.max(4, ...rows.map((r) => r.host.length));
|
|
1070
1618
|
const pidW = Math.max(3, ...rows.map((r) => String(r.rec.pid).length));
|
|
1071
1619
|
const cliW = Math.max(3, ...rows.map((r) => String(r.rec.cli).length));
|
|
1072
1620
|
const statusW = Math.max(6, ...rows.map((r) => String(r.rec.status).length));
|
|
1621
|
+
const ageW = Math.max(3, ...rows.map((r) => ageOf(r.rec).length));
|
|
1073
1622
|
const cwdW = Math.max(3, ...rows.map((r) => shortenPath(String(r.rec.cwd)).length));
|
|
1074
|
-
const promptBudget = Math.max(20, termWidth - hostW - pidW - cliW - statusW - cwdW -
|
|
1623
|
+
const promptBudget = Math.max(20, termWidth - hostW - pidW - cliW - statusW - ageW - cwdW - 12 - 1);
|
|
1075
1624
|
process.stdout.write([
|
|
1076
1625
|
"HOST".padEnd(hostW),
|
|
1077
1626
|
"PID".padEnd(pidW),
|
|
1078
1627
|
"CLI".padEnd(cliW),
|
|
1079
1628
|
"STATUS".padEnd(statusW),
|
|
1629
|
+
"AGE".padEnd(ageW),
|
|
1080
1630
|
"CWD".padEnd(cwdW),
|
|
1081
1631
|
"PROMPT"
|
|
1082
1632
|
].join(" ") + "\n");
|
|
1083
|
-
for (const { host, rec } of rows) {
|
|
1084
|
-
const
|
|
1633
|
+
for (const { host, rec, prefix } of rows) {
|
|
1634
|
+
const flagStr = badgeLabels(rec.badges);
|
|
1635
|
+
const branchStr = branchLabel(rec.git);
|
|
1636
|
+
const gitStr = gitLabel(rec.git);
|
|
1637
|
+
const deco = badgeOf(rec) + (flagStr ? flagStr + " " : "") + (branchStr ? branchStr + " " : "") + (gitStr ? gitStr + " " : "");
|
|
1638
|
+
const budget = Math.max(8, promptBudget - prefix.length - deco.length);
|
|
1639
|
+
const label = prefix + deco + (rec.prompt ? truncate(`→ ${rec.prompt}`, budget) : "");
|
|
1085
1640
|
process.stdout.write([
|
|
1086
1641
|
host.padEnd(hostW),
|
|
1087
1642
|
String(rec.pid).padEnd(pidW),
|
|
1088
1643
|
String(rec.cli).padEnd(cliW),
|
|
1089
1644
|
String(rec.status).padEnd(statusW),
|
|
1645
|
+
ageOf(rec).padEnd(ageW),
|
|
1090
1646
|
shortenPath(String(rec.cwd)).padEnd(cwdW),
|
|
1091
1647
|
label
|
|
1092
1648
|
].join(" ") + "\n");
|
|
@@ -1142,6 +1698,133 @@ async function deriveLiveState(r) {
|
|
|
1142
1698
|
question: null
|
|
1143
1699
|
};
|
|
1144
1700
|
}
|
|
1701
|
+
/**
|
|
1702
|
+
* When the agent last wrote stdout — the log file's mtime, falling back to
|
|
1703
|
+
* started_at when there's no log yet (freshly spawned). Mirrors serve.ts's
|
|
1704
|
+
* `last_active_at`, so the `ay ls` AGE column measures STALENESS (time since the
|
|
1705
|
+
* agent last produced output) rather than lifetime — matching the console's
|
|
1706
|
+
* left-panel age. A long-lived but quiet agent then reads as stale, not "new".
|
|
1707
|
+
*/
|
|
1708
|
+
async function deriveLastActiveAt(r) {
|
|
1709
|
+
if (!r.log_file) return r.started_at;
|
|
1710
|
+
return stat(r.log_file).then((s) => s.mtimeMs).catch(() => r.started_at);
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Format a GitInfo into the console's compact tag: "±3" changed files, "⑂2"
|
|
1714
|
+
* submodule pin-bumps, "⊙1" submodule internal dirt, "↑1" ahead, "↓2" behind.
|
|
1715
|
+
* Mirrors gitLabel() in lab/ui/console-logic.js so `ay ls` and the web panel's
|
|
1716
|
+
* left rail read identically. "" when clean / in sync / not a repo.
|
|
1717
|
+
*/
|
|
1718
|
+
function gitLabel(g) {
|
|
1719
|
+
if (!g) return "";
|
|
1720
|
+
const parts = [];
|
|
1721
|
+
if (g.changed > 0) parts.push("±" + g.changed);
|
|
1722
|
+
if (g.pins > 0) parts.push("⑂" + g.pins);
|
|
1723
|
+
if (g.subDirty > 0) parts.push("⊙" + g.subDirty);
|
|
1724
|
+
if (g.ahead > 0) parts.push("↑" + g.ahead);
|
|
1725
|
+
if (g.behind > 0) parts.push("↓" + g.behind);
|
|
1726
|
+
return parts.join(" ");
|
|
1727
|
+
}
|
|
1728
|
+
/**
|
|
1729
|
+
* The checked-out branch as "⎇<name>" (⎇ = the branch/alt-key glyph). Shows the
|
|
1730
|
+
* ACTUAL git branch, which can differ from the worktree folder in the cwd (a
|
|
1731
|
+
* feature branch checked out in .../tree/main, a detached HEAD, etc.). "" when
|
|
1732
|
+
* detached / not a repo. Kept separate from gitLabel so gitLabel stays a mirror
|
|
1733
|
+
* of the web console's tag.
|
|
1734
|
+
*/
|
|
1735
|
+
function branchLabel(g) {
|
|
1736
|
+
return g?.branch ? "⎇" + g.branch : "";
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Short status-flag chips ("goal", "retry", "limit") for a list of badge ids —
|
|
1740
|
+
* the same flags the console shows, resolved to their labels via badges.ts.
|
|
1741
|
+
* "" when none. (Remote records carry `badges` from /api/ls; local ones are
|
|
1742
|
+
* matched here via extractBadges.)
|
|
1743
|
+
*/
|
|
1744
|
+
function badgeLabels(ids) {
|
|
1745
|
+
if (!ids || ids.length === 0) return "";
|
|
1746
|
+
return ids.map((id) => badgeDef(id)?.label ?? id).join(" ");
|
|
1747
|
+
}
|
|
1748
|
+
function parseGitStatus(out) {
|
|
1749
|
+
let branch = null;
|
|
1750
|
+
let ahead = 0;
|
|
1751
|
+
let behind = 0;
|
|
1752
|
+
let changed = 0;
|
|
1753
|
+
let pins = 0;
|
|
1754
|
+
let subDirty = 0;
|
|
1755
|
+
for (const line of out.split("\n")) {
|
|
1756
|
+
if (line.length === 0) continue;
|
|
1757
|
+
if (line[0] === "#") {
|
|
1758
|
+
const head = /^# branch\.head (.+)$/.exec(line);
|
|
1759
|
+
if (head) {
|
|
1760
|
+
branch = head[1] === "(detached)" ? null : head[1];
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
|
|
1764
|
+
if (ab) {
|
|
1765
|
+
ahead = Number(ab[1]);
|
|
1766
|
+
behind = Number(ab[2]);
|
|
1767
|
+
}
|
|
1768
|
+
continue;
|
|
1769
|
+
}
|
|
1770
|
+
const type = line[0];
|
|
1771
|
+
if (type === "?" || type === "u") changed++;
|
|
1772
|
+
else if (type === "1" || type === "2") {
|
|
1773
|
+
const sub = line.split(" ")[2] ?? "N...";
|
|
1774
|
+
if (sub[0] === "S") if (sub[1] === "C") pins++;
|
|
1775
|
+
else subDirty++;
|
|
1776
|
+
else changed++;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
return {
|
|
1780
|
+
branch,
|
|
1781
|
+
dirty: changed > 0,
|
|
1782
|
+
changed,
|
|
1783
|
+
pins,
|
|
1784
|
+
subDirty,
|
|
1785
|
+
ahead,
|
|
1786
|
+
behind
|
|
1787
|
+
};
|
|
1788
|
+
}
|
|
1789
|
+
async function runGitCli(args, cwd) {
|
|
1790
|
+
try {
|
|
1791
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
1792
|
+
cwd,
|
|
1793
|
+
stdout: "pipe",
|
|
1794
|
+
stderr: "ignore",
|
|
1795
|
+
signal: AbortSignal.timeout(2e3)
|
|
1796
|
+
});
|
|
1797
|
+
const out = await new Response(proc.stdout).text();
|
|
1798
|
+
await proc.exited;
|
|
1799
|
+
return proc.exitCode === 0 ? out : null;
|
|
1800
|
+
} catch {
|
|
1801
|
+
return null;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* One-shot git status for the `ay ls` CLI. serve.ts keeps a per-repo watcher so
|
|
1806
|
+
* its request path never spawns git; a one-shot CLI has no watcher, so it spawns
|
|
1807
|
+
* `git status` directly — but deduped per repo root via the two caches, so N
|
|
1808
|
+
* agents sharing a repo (or its submodules/subdirs) cost ONE `git status`.
|
|
1809
|
+
*/
|
|
1810
|
+
async function gitStatusOnce(cwd, rootCache, infoCache) {
|
|
1811
|
+
if (!cwd) return null;
|
|
1812
|
+
let root = rootCache.get(cwd);
|
|
1813
|
+
if (root === void 0) {
|
|
1814
|
+
root = (await runGitCli(["rev-parse", "--show-toplevel"], cwd) ?? "").trim();
|
|
1815
|
+
rootCache.set(cwd, root);
|
|
1816
|
+
}
|
|
1817
|
+
if (!root) return null;
|
|
1818
|
+
if (infoCache.has(root)) return infoCache.get(root);
|
|
1819
|
+
const out = await runGitCli([
|
|
1820
|
+
"status",
|
|
1821
|
+
"--porcelain=v2",
|
|
1822
|
+
"--branch"
|
|
1823
|
+
], root);
|
|
1824
|
+
const info = out != null ? parseGitStatus(out) : null;
|
|
1825
|
+
infoCache.set(root, info);
|
|
1826
|
+
return info;
|
|
1827
|
+
}
|
|
1145
1828
|
async function cmdLs(rest) {
|
|
1146
1829
|
const y = yargs(rest).usage("Usage: ay ls [keyword] [options]\n ay list [keyword] [options]\n ay ps [keyword] [options]\n\nList running agents. Optionally filter by keyword (pid, cwd substring, or prompt substring).").option("all", {
|
|
1147
1830
|
type: "boolean",
|
|
@@ -1259,37 +1942,57 @@ async function cmdLs(rest) {
|
|
|
1259
1942
|
return 0;
|
|
1260
1943
|
}
|
|
1261
1944
|
const termWidth = process.stdout.columns ?? 120;
|
|
1945
|
+
const now = Date.now();
|
|
1946
|
+
const lastActive = new Map(await Promise.all(records.map(async (r) => [r.pid, await deriveLastActiveAt(r)])));
|
|
1947
|
+
const ageOf = (r) => humanizeAge(now - (lastActive.get(r.pid) ?? r.started_at));
|
|
1262
1948
|
const rawCwds = records.map((r) => shortenPath(r.cwd));
|
|
1263
1949
|
const widths = {
|
|
1264
1950
|
pid: Math.max(3, ...records.map((r) => String(r.pid).length)),
|
|
1265
1951
|
cli: Math.max(3, ...records.map((r) => r.cli.length)),
|
|
1266
1952
|
status: Math.max(6, ...records.map((r) => r.status.length)),
|
|
1267
|
-
age: Math.max(3, ...records.map((r) =>
|
|
1953
|
+
age: Math.max(3, ...records.map((r) => ageOf(r).length)),
|
|
1268
1954
|
cwd: Math.max(3, ...rawCwds.map((c) => c.length))
|
|
1269
1955
|
};
|
|
1270
1956
|
const fixedWidth = widths.pid + widths.cli + widths.status + widths.age + widths.cwd + 10;
|
|
1271
1957
|
const promptBudget = Math.max(20, termWidth - fixedWidth - 1);
|
|
1272
1958
|
const forestRows = flattenForest(buildAgentForest(records));
|
|
1273
1959
|
const notes = await readNotes();
|
|
1960
|
+
const gitRootCache = /* @__PURE__ */ new Map();
|
|
1961
|
+
const gitInfoCache = /* @__PURE__ */ new Map();
|
|
1274
1962
|
const rows = await Promise.all(forestRows.map(async ({ record: r, prefix }) => {
|
|
1275
1963
|
const displayStatus = (await deriveLiveState(r)).state;
|
|
1964
|
+
const alive = displayStatus !== "stopped";
|
|
1276
1965
|
const note = notes.get(r.pid);
|
|
1277
|
-
const tasks
|
|
1278
|
-
|
|
1279
|
-
|
|
1966
|
+
const [tasks, flags, git, typing] = alive ? await Promise.all([
|
|
1967
|
+
r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
|
|
1968
|
+
r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
|
|
1969
|
+
gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
|
|
1970
|
+
isUserTyping(r.pid)
|
|
1971
|
+
]) : [
|
|
1972
|
+
null,
|
|
1973
|
+
[],
|
|
1974
|
+
null,
|
|
1975
|
+
false
|
|
1976
|
+
];
|
|
1977
|
+
const taskBadge = tasks ? `${tasks.done}/${tasks.total} ` : "";
|
|
1978
|
+
const flagStr = badgeLabels(typing ? [...flags, TYPING_BADGE.id] : flags);
|
|
1979
|
+
const branchStr = branchLabel(git);
|
|
1980
|
+
const gitStr = gitLabel(git);
|
|
1981
|
+
const deco = taskBadge + (flagStr ? flagStr + " " : "") + (branchStr ? branchStr + " " : "") + (gitStr ? gitStr + " " : "");
|
|
1982
|
+
const budget = Math.max(8, promptBudget - prefix.length - deco.length);
|
|
1280
1983
|
let label;
|
|
1281
1984
|
let hasNote = false;
|
|
1282
1985
|
if (note) {
|
|
1283
1986
|
label = truncate(note, budget);
|
|
1284
1987
|
hasNote = true;
|
|
1285
|
-
} else if (r.log_file &&
|
|
1988
|
+
} else if (r.log_file && alive) label = truncate(await extractActivity(r.log_file) ?? (r.prompt ? `→ ${r.prompt}` : ""), budget);
|
|
1286
1989
|
else label = truncate(r.prompt ? `→ ${r.prompt}` : "", budget);
|
|
1287
|
-
label = prefix + (hasNote ? "* " : "") +
|
|
1990
|
+
label = prefix + (hasNote ? "* " : "") + deco + label;
|
|
1288
1991
|
return {
|
|
1289
1992
|
pid: String(r.pid),
|
|
1290
1993
|
cli: r.cli,
|
|
1291
1994
|
status: displayStatus,
|
|
1292
|
-
age:
|
|
1995
|
+
age: ageOf(r),
|
|
1293
1996
|
cwd: shortenPath(r.cwd),
|
|
1294
1997
|
label,
|
|
1295
1998
|
hasNote,
|
|
@@ -1894,6 +2597,20 @@ async function extractBadges(logPath) {
|
|
|
1894
2597
|
if (!lines) return [];
|
|
1895
2598
|
return matchBadges(lines);
|
|
1896
2599
|
}
|
|
2600
|
+
const TYPING_WINDOW_MS = 3e3;
|
|
2601
|
+
function stdinActivityPath(pid) {
|
|
2602
|
+
return path.resolve(agentYesHome(), "activity", `${pid}.stdin`);
|
|
2603
|
+
}
|
|
2604
|
+
async function lastStdinAt(pid) {
|
|
2605
|
+
const raw = await readFile(stdinActivityPath(pid), "utf-8").catch(() => null);
|
|
2606
|
+
if (raw === null) return null;
|
|
2607
|
+
const ms = Number(raw.trim());
|
|
2608
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
2609
|
+
}
|
|
2610
|
+
async function isUserTyping(pid, windowMs = TYPING_WINDOW_MS) {
|
|
2611
|
+
const at = await lastStdinAt(pid);
|
|
2612
|
+
return at !== null && Date.now() - at <= windowMs;
|
|
2613
|
+
}
|
|
1897
2614
|
/**
|
|
1898
2615
|
* Whether an alive agent is wedged: its log has been silent for at least
|
|
1899
2616
|
* STUCK_THRESHOLD_MS yet its screen still shows a `working` busy marker (a live
|
|
@@ -2038,6 +2755,82 @@ async function extractMenu(logPath, cli) {
|
|
|
2038
2755
|
working: cfg.working
|
|
2039
2756
|
});
|
|
2040
2757
|
}
|
|
2758
|
+
/**
|
|
2759
|
+
* Poll `logFile`'s size until it goes `quietMs` without changing, or `maxWaitMs`
|
|
2760
|
+
* total elapses (whichever first). Returns the final observed size, or null if
|
|
2761
|
+
* the file can't be stat'd. Used by `ay send` both to wait out a paste's render
|
|
2762
|
+
* (before submitting) and to detect whether a submit actually produced output
|
|
2763
|
+
* (after submitting).
|
|
2764
|
+
*/
|
|
2765
|
+
async function waitForLogQuiet(logFile, quietMs, maxWaitMs) {
|
|
2766
|
+
const pollMs = 50;
|
|
2767
|
+
let lastSize = null;
|
|
2768
|
+
let lastChangeAt = Date.now();
|
|
2769
|
+
const deadline = Date.now() + maxWaitMs;
|
|
2770
|
+
while (Date.now() < deadline) {
|
|
2771
|
+
const size = await stat(logFile).then((s) => s.size).catch(() => null);
|
|
2772
|
+
if (size === null) return null;
|
|
2773
|
+
if (size !== lastSize) {
|
|
2774
|
+
lastSize = size;
|
|
2775
|
+
lastChangeAt = Date.now();
|
|
2776
|
+
} else if (Date.now() - lastChangeAt >= quietMs) return lastSize;
|
|
2777
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
2778
|
+
}
|
|
2779
|
+
return lastSize;
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* Block while the user is typing at `pid`'s terminal, so `ay send` doesn't inject
|
|
2783
|
+
* mid-line. Polls the stdin-activity marker every SEND_TYPING_POLL_MS until the
|
|
2784
|
+
* user pauses (last keystroke older than the typing window) or `maxWaitMs`
|
|
2785
|
+
* elapses. Returns `{ clear, waitedMs }`: `clear` is true if they paused, false
|
|
2786
|
+
* if still typing at the deadline (caller sends anyway, with a warning).
|
|
2787
|
+
*/
|
|
2788
|
+
async function backoffWhileTyping(pid, maxWaitMs) {
|
|
2789
|
+
const start = Date.now();
|
|
2790
|
+
const deadline = start + maxWaitMs;
|
|
2791
|
+
let waited = false;
|
|
2792
|
+
while (Date.now() < deadline) {
|
|
2793
|
+
if (!await isUserTyping(pid)) return {
|
|
2794
|
+
clear: true,
|
|
2795
|
+
waitedMs: waited ? Date.now() - start : 0
|
|
2796
|
+
};
|
|
2797
|
+
waited = true;
|
|
2798
|
+
await new Promise((r) => setTimeout(r, SEND_TYPING_POLL_MS));
|
|
2799
|
+
}
|
|
2800
|
+
return {
|
|
2801
|
+
clear: false,
|
|
2802
|
+
waitedMs: Date.now() - start
|
|
2803
|
+
};
|
|
2804
|
+
}
|
|
2805
|
+
/**
|
|
2806
|
+
* Send the trailing submit code and confirm the CLI actually acted on it —
|
|
2807
|
+
* either a `working` busy marker appears, or the log grows meaningfully. Retries
|
|
2808
|
+
* (re-sending just the trailing code) up to SEND_SUBMIT_MAX_RETRIES times when
|
|
2809
|
+
* neither shows, since a swallowed Enter looks identical to a slow one until we
|
|
2810
|
+
* check. Returns whether submission was confirmed, plus the final rendered tail
|
|
2811
|
+
* (for a caller to show the user when it wasn't).
|
|
2812
|
+
*/
|
|
2813
|
+
async function submitAndConfirm(record, fifoPath, trailing) {
|
|
2814
|
+
const logFile = record.log_file;
|
|
2815
|
+
const cfg = (await cliDefaults())[record.cli];
|
|
2816
|
+
let screen = [];
|
|
2817
|
+
for (let attempt = 0; attempt <= SEND_SUBMIT_MAX_RETRIES; attempt++) {
|
|
2818
|
+
const sizeBefore = await stat(logFile).then((s) => s.size).catch(() => null) ?? 0;
|
|
2819
|
+
const wasAlreadyWorking = isWorkingScreen(await renderLogTailLines(logFile, 40) ?? [], cfg?.working);
|
|
2820
|
+
await writeToIpc(fifoPath, trailing);
|
|
2821
|
+
const sizeAfter = await waitForLogQuiet(logFile, SEND_CONFIRM_QUIET_MS, SEND_CONFIRM_MAX_MS);
|
|
2822
|
+
screen = await renderLogTailLines(logFile, 40) ?? [];
|
|
2823
|
+
const grew = sizeAfter !== null && sizeAfter >= sizeBefore + SEND_CONFIRM_MIN_GROWTH_BYTES;
|
|
2824
|
+
if (isWorkingScreen(screen, cfg?.working) && !wasAlreadyWorking || grew) return {
|
|
2825
|
+
confirmed: true,
|
|
2826
|
+
screen
|
|
2827
|
+
};
|
|
2828
|
+
}
|
|
2829
|
+
return {
|
|
2830
|
+
confirmed: false,
|
|
2831
|
+
screen
|
|
2832
|
+
};
|
|
2833
|
+
}
|
|
2041
2834
|
/** Poll until the agent is no longer parked on a menu (selection accepted → it
|
|
2042
2835
|
* resumed / moved on) or the deadline passes. Returns true if it cleared. */
|
|
2043
2836
|
async function waitForNeedsInputClear(record, timeoutMs) {
|
|
@@ -2049,7 +2842,7 @@ async function waitForNeedsInputClear(record, timeoutMs) {
|
|
|
2049
2842
|
return false;
|
|
2050
2843
|
}
|
|
2051
2844
|
async function cmdSend(rest) {
|
|
2052
|
-
const argv = await yargs(rest).usage("Usage: ay send <keyword> <msg|-> [options]").option("code", {
|
|
2845
|
+
const argv = await yargs(rest).parserConfiguration({ "boolean-negation": false }).usage("Usage: ay send <keyword> <msg|-> [options]").option("code", {
|
|
2053
2846
|
type: "string",
|
|
2054
2847
|
default: "enter",
|
|
2055
2848
|
description: "Trailing control code (enter|esc|ctrl-c|ctrl-y|tab|none)"
|
|
@@ -2067,7 +2860,12 @@ async function cmdSend(rest) {
|
|
|
2067
2860
|
}).option("force", {
|
|
2068
2861
|
type: "boolean",
|
|
2069
2862
|
default: false,
|
|
2070
|
-
description: "Skip the 'tailed recently' safety check (also: AGENT_YES_FORCE_SEND=1)"
|
|
2863
|
+
description: "Skip the 'tailed recently' safety check and the wait-while-user-typing backoff (also: AGENT_YES_FORCE_SEND=1)"
|
|
2864
|
+
}).option("no-wait", {
|
|
2865
|
+
type: "boolean",
|
|
2866
|
+
default: false,
|
|
2867
|
+
alias: "async",
|
|
2868
|
+
description: "Fire-and-forget: skip the paste-settle wait and submit confirmation, don't retry a swallowed Enter (also: AGENT_YES_SEND_NO_WAIT=1)"
|
|
2071
2869
|
}).help(false).version(false).exitProcess(false).parseAsync();
|
|
2072
2870
|
const opts = {
|
|
2073
2871
|
all: argv.all,
|
|
@@ -2094,7 +2892,8 @@ async function cmdSend(rest) {
|
|
|
2094
2892
|
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2095
2893
|
body = Buffer.concat(chunks).toString("utf-8").trimEnd();
|
|
2096
2894
|
} else body = rawMessage;
|
|
2097
|
-
const
|
|
2895
|
+
const force = Boolean(argv.force) || process.env.AGENT_YES_FORCE_SEND === "1";
|
|
2896
|
+
const sender = await enforceSendGuards(record, force);
|
|
2098
2897
|
if (isExitRequest(body)) {
|
|
2099
2898
|
const reason = sender.agent ? `requested by ${sender.agent.cli} #${sender.agent.pid} @ ${shortenPath(sender.agent.cwd)}` : `requested via 'ay send ${keyword} exit'`;
|
|
2100
2899
|
const { strategy } = await gracefulExitAgent(record, reason);
|
|
@@ -2102,20 +2901,36 @@ async function cmdSend(rest) {
|
|
|
2102
2901
|
return 0;
|
|
2103
2902
|
}
|
|
2104
2903
|
const fullBody = (sender.agent && !isSlashCommand(body) ? `[from ${sender.agent.cli} #${sender.agent.pid} @ ${shortenPath(sender.agent.cwd)} — reply: ay send ${sender.agent.pid} "..."]\n` : "") + body;
|
|
2904
|
+
const noWait = Boolean(argv.noWait) || process.env.AGENT_YES_SEND_NO_WAIT === "1";
|
|
2905
|
+
if (fullBody && !noWait && !force) {
|
|
2906
|
+
const { clear, waitedMs } = await backoffWhileTyping(record.pid, SEND_TYPING_MAX_WAIT_MS);
|
|
2907
|
+
if (!clear) process.stderr.write(`warning: user still typing at pid ${record.pid} after ${Math.round(waitedMs / 1e3)}s — sending anyway (may interleave with their line). Use --force to skip this wait.\n`);
|
|
2908
|
+
else if (waitedMs > 0) process.stderr.write(`waited ${Math.round(waitedMs / 1e3)}s for the user to pause typing before sending.\n`);
|
|
2909
|
+
}
|
|
2910
|
+
const canConfirm = trailing === "\r" && Boolean(fullBody) && !noWait;
|
|
2911
|
+
let confirmed = true;
|
|
2912
|
+
let lastScreen = [];
|
|
2105
2913
|
if (fullBody && trailing) {
|
|
2106
2914
|
await writeToIpc(fifoPath, fullBody);
|
|
2107
|
-
|
|
2108
|
-
|
|
2915
|
+
if (canConfirm && record.log_file) {
|
|
2916
|
+
await waitForLogQuiet(record.log_file, SEND_SETTLE_QUIET_MS, SEND_SETTLE_MAX_MS);
|
|
2917
|
+
({confirmed, screen: lastScreen} = await submitAndConfirm(record, fifoPath, trailing));
|
|
2918
|
+
} else {
|
|
2919
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2920
|
+
await writeToIpc(fifoPath, trailing);
|
|
2921
|
+
}
|
|
2109
2922
|
} else await writeToIpc(fifoPath, fullBody + trailing);
|
|
2110
2923
|
const payload = body + trailing;
|
|
2111
|
-
|
|
2924
|
+
const status = confirmed ? "sent" : "sent but NOT confirmed submitted";
|
|
2925
|
+
process.stdout.write(`${status} to pid ${record.pid} (${record.cli}): ${truncate(payload, 80)}\n`);
|
|
2926
|
+
if (!confirmed) process.stderr.write(`\nwarning: couldn't confirm the CLI acted on it after ${SEND_SUBMIT_MAX_RETRIES + 1} attempt(s) — it may still be sitting unsubmitted in the prompt. Last screen:\n` + lastScreen.slice(-8).map((l) => ` ${l}`).join("\n") + "\n");
|
|
2112
2927
|
const replyHint = sender.agent ? ` ay send ${sender.agent.pid} "..." # reply to sender\n` : "";
|
|
2113
2928
|
process.stderr.write(`\n` + replyHint + ` ay tail ${record.pid} # watch output\n ay ls # list all agents\n`);
|
|
2114
2929
|
if (codeName === "ctrl-c" || codeName === "ctrlc") {
|
|
2115
2930
|
const tip = stopTipForCli(record.cli, record.pid);
|
|
2116
2931
|
if (tip) process.stderr.write(tip);
|
|
2117
2932
|
}
|
|
2118
|
-
return 0;
|
|
2933
|
+
return confirmed ? 0 : 1;
|
|
2119
2934
|
}
|
|
2120
2935
|
async function resolveWritableAgent(keyword, opts) {
|
|
2121
2936
|
const record = await resolveOne(keyword, opts);
|
|
@@ -3056,7 +3871,197 @@ async function cmdResultSet(rest) {
|
|
|
3056
3871
|
process.stdout.write(`result envelope written for pid ${pid}\n`);
|
|
3057
3872
|
return 0;
|
|
3058
3873
|
}
|
|
3874
|
+
/** Resolve the parent pid a `ay notify` invocation is draining. */
|
|
3875
|
+
function resolveParentPid(explicit) {
|
|
3876
|
+
if (Number.isFinite(explicit) && explicit > 0) return explicit;
|
|
3877
|
+
const self = Number(process.env.AGENT_YES_PID);
|
|
3878
|
+
if (Number.isFinite(self) && self > 0) return self;
|
|
3879
|
+
throw new Error("ay notify: not running inside an agent (no AGENT_YES_PID) — pass --parent <pid>");
|
|
3880
|
+
}
|
|
3881
|
+
function printNotifyEvents(events, json) {
|
|
3882
|
+
if (json) {
|
|
3883
|
+
for (const e of events) process.stdout.write(JSON.stringify(e) + "\n");
|
|
3884
|
+
return;
|
|
3885
|
+
}
|
|
3886
|
+
for (const e of events) {
|
|
3887
|
+
const tag = `[${e.edge}]`;
|
|
3888
|
+
const head = `[${e.seq}] ${tag} pid ${e.child_pid} (${e.cli}) ${e.cwd}`;
|
|
3889
|
+
process.stdout.write(head + "\n");
|
|
3890
|
+
if (e.git_head) process.stdout.write(` HEAD ${e.git_head}\n`);
|
|
3891
|
+
if (e.question) process.stdout.write(` Q: ${e.question}\n`);
|
|
3892
|
+
if (e.tail) process.stdout.write(e.tail.split("\n").map((l) => ` | ${l}`).join("\n") + "\n");
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
3895
|
+
async function cmdNotify(rest) {
|
|
3896
|
+
const verb = rest[0];
|
|
3897
|
+
const args = rest.slice(1);
|
|
3898
|
+
if (verb === "cursor") return cmdNotifyCursor(args);
|
|
3899
|
+
if (verb !== "read" && verb !== "watch") {
|
|
3900
|
+
process.stderr.write("usage: ay notify <read|watch|cursor> [--parent <pid>] [--since <seq>] [--since-ts <ms>] [--unread] [--ack] [--json]\n");
|
|
3901
|
+
return 1;
|
|
3902
|
+
}
|
|
3903
|
+
const argv = await yargs(args).option("parent", {
|
|
3904
|
+
type: "number",
|
|
3905
|
+
description: "Parent pid whose inbox to drain (default: $AGENT_YES_PID)"
|
|
3906
|
+
}).option("since", {
|
|
3907
|
+
type: "number",
|
|
3908
|
+
description: "Only edges with seq greater than this"
|
|
3909
|
+
}).option("since-ts", {
|
|
3910
|
+
type: "number",
|
|
3911
|
+
description: "Only edges at/after this epoch-ms"
|
|
3912
|
+
}).option("unread", {
|
|
3913
|
+
type: "boolean",
|
|
3914
|
+
default: false,
|
|
3915
|
+
description: "Only edges past the saved cursor"
|
|
3916
|
+
}).option("ack", {
|
|
3917
|
+
type: "boolean",
|
|
3918
|
+
default: false,
|
|
3919
|
+
description: "Advance the cursor past what's shown (at-least-once: off by default)"
|
|
3920
|
+
}).option("json", {
|
|
3921
|
+
type: "boolean",
|
|
3922
|
+
default: false,
|
|
3923
|
+
description: "Emit raw NDJSON events"
|
|
3924
|
+
}).option("consumer", {
|
|
3925
|
+
type: "string",
|
|
3926
|
+
default: "parent",
|
|
3927
|
+
description: "Cursor identity (for multiple readers)"
|
|
3928
|
+
}).option("interval", {
|
|
3929
|
+
type: "number",
|
|
3930
|
+
default: 2,
|
|
3931
|
+
description: "Poll interval in seconds (watch)"
|
|
3932
|
+
}).option("ensure-daemon", {
|
|
3933
|
+
type: "boolean",
|
|
3934
|
+
default: true,
|
|
3935
|
+
description: "Start the notifyd singleton if not running (watch)"
|
|
3936
|
+
}).help(false).version(false).exitProcess(false).parseAsync();
|
|
3937
|
+
const parent = resolveParentPid(argv.parent);
|
|
3938
|
+
const host = hostId();
|
|
3939
|
+
const consumer = String(argv.consumer);
|
|
3940
|
+
const selfStartedAt = await resolveParentStartedAt(parent);
|
|
3941
|
+
if (selfStartedAt <= 0) throw new Error(`cannot resolve identity for pid ${parent} (no live agent record) — refusing to open the notification path (pass --parent for a live agent).`);
|
|
3942
|
+
const drain = async (sinceSeqOverride) => {
|
|
3943
|
+
let events = await readInbox(host, parent);
|
|
3944
|
+
if (selfStartedAt > 0) events = events.filter((e) => e.parent_started_at === selfStartedAt);
|
|
3945
|
+
if (argv.unread) {
|
|
3946
|
+
const cursor = await getCursor(host, parent, consumer);
|
|
3947
|
+
events = filterUnread(events, sinceSeqOverride ?? cursor);
|
|
3948
|
+
} else {
|
|
3949
|
+
if (sinceSeqOverride !== void 0) events = filterSinceSeq(events, sinceSeqOverride);
|
|
3950
|
+
else if (Number.isFinite(argv.since)) events = filterSinceSeq(events, argv.since);
|
|
3951
|
+
if (Number.isFinite(argv["since-ts"])) events = filterSinceTs(events, argv["since-ts"]);
|
|
3952
|
+
}
|
|
3953
|
+
printNotifyEvents(events, argv.json);
|
|
3954
|
+
return maxSeq(events);
|
|
3955
|
+
};
|
|
3956
|
+
const ackTo = async (seq) => {
|
|
3957
|
+
if (seq > await getCursor(host, parent, consumer)) await setCursor(host, parent, seq, consumer);
|
|
3958
|
+
};
|
|
3959
|
+
if (verb === "read") {
|
|
3960
|
+
const top = await drain();
|
|
3961
|
+
if (argv.ack && top > 0) await ackTo(top);
|
|
3962
|
+
return 0;
|
|
3963
|
+
}
|
|
3964
|
+
const ensure = async () => {
|
|
3965
|
+
if (!argv["ensure-daemon"]) return;
|
|
3966
|
+
const { ensureDaemon } = await import("./notifyDaemon-D5jtpCAR.js");
|
|
3967
|
+
await ensureDaemon().catch(() => null);
|
|
3968
|
+
};
|
|
3969
|
+
await heartbeatWatcher(parent, selfStartedAt);
|
|
3970
|
+
await ensure();
|
|
3971
|
+
const intervalMs = Math.max(500, (Number.isFinite(argv.interval) ? argv.interval : 2) * 1e3);
|
|
3972
|
+
let lastSeq = argv.unread ? await getCursor(host, parent, consumer) : Number.isFinite(argv.since) ? argv.since : maxSeq(await readInbox(host, parent));
|
|
3973
|
+
let acked = lastSeq;
|
|
3974
|
+
let stop = false;
|
|
3975
|
+
const onSig = () => {
|
|
3976
|
+
stop = true;
|
|
3977
|
+
clearWatcher(parent).finally(() => process.exit(0));
|
|
3978
|
+
};
|
|
3979
|
+
process.on("SIGINT", onSig);
|
|
3980
|
+
process.on("SIGTERM", onSig);
|
|
3981
|
+
try {
|
|
3982
|
+
while (!stop) {
|
|
3983
|
+
await heartbeatWatcher(parent, selfStartedAt);
|
|
3984
|
+
await ensure();
|
|
3985
|
+
const top = await drain(lastSeq);
|
|
3986
|
+
if (top > lastSeq) lastSeq = top;
|
|
3987
|
+
if (argv.ack && lastSeq > acked) {
|
|
3988
|
+
await ackTo(lastSeq);
|
|
3989
|
+
acked = lastSeq;
|
|
3990
|
+
}
|
|
3991
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
3992
|
+
}
|
|
3993
|
+
} finally {
|
|
3994
|
+
await clearWatcher(parent);
|
|
3995
|
+
}
|
|
3996
|
+
return 0;
|
|
3997
|
+
}
|
|
3998
|
+
/**
|
|
3999
|
+
* Resolve the started_at of the LIVE agent whose wrapper pid is `parent`. Returns
|
|
4000
|
+
* 0 (→ the caller fails closed) when there is no such record, when the matching
|
|
4001
|
+
* record is stale (exited / pid not alive), or when the match is ambiguous
|
|
4002
|
+
* (>1 live record) — so a leftover stale record can't make a recycled parent pid
|
|
4003
|
+
* resolve to a PRIOR incarnation's start time and fail-open the identity guard.
|
|
4004
|
+
*/
|
|
4005
|
+
async function resolveParentStartedAt(parent) {
|
|
4006
|
+
const live = (await listRecords(void 0, {
|
|
4007
|
+
all: true,
|
|
4008
|
+
active: false,
|
|
4009
|
+
json: false,
|
|
4010
|
+
latest: false,
|
|
4011
|
+
cwdScope: null
|
|
4012
|
+
}).catch(() => [])).filter((r) => (r.wrapper_pid === parent || r.pid === parent) && r.status !== "exited" && isPidAlive(r.pid));
|
|
4013
|
+
if (live.length !== 1) return 0;
|
|
4014
|
+
return live[0].started_at ?? 0;
|
|
4015
|
+
}
|
|
4016
|
+
async function cmdNotifyCursor(args) {
|
|
4017
|
+
const action = args[0];
|
|
4018
|
+
const argv = await yargs(args.slice(1)).option("parent", { type: "number" }).option("consumer", {
|
|
4019
|
+
type: "string",
|
|
4020
|
+
default: "parent"
|
|
4021
|
+
}).help(false).version(false).exitProcess(false).parseAsync();
|
|
4022
|
+
const parent = resolveParentPid(argv.parent);
|
|
4023
|
+
const host = hostId();
|
|
4024
|
+
const consumer = String(argv.consumer);
|
|
4025
|
+
if (action === "get") {
|
|
4026
|
+
process.stdout.write(String(await getCursor(host, parent, consumer)) + "\n");
|
|
4027
|
+
return 0;
|
|
4028
|
+
}
|
|
4029
|
+
if (action === "set") {
|
|
4030
|
+
const seq = Number(argv._[0]);
|
|
4031
|
+
if (!Number.isFinite(seq) || seq < 0) throw new Error("ay notify cursor set <seq>");
|
|
4032
|
+
await setCursor(host, parent, seq, consumer);
|
|
4033
|
+
return 0;
|
|
4034
|
+
}
|
|
4035
|
+
process.stderr.write("usage: ay notify cursor <get|set <seq>> [--parent <pid>]\n");
|
|
4036
|
+
return 1;
|
|
4037
|
+
}
|
|
4038
|
+
async function cmdNotifyd(rest) {
|
|
4039
|
+
const sub = rest[0] ?? "status";
|
|
4040
|
+
const daemon = await import("./notifyDaemon-D5jtpCAR.js");
|
|
4041
|
+
switch (sub) {
|
|
4042
|
+
case "run": return daemon.runDaemon();
|
|
4043
|
+
case "once": return daemon.runDaemon({ once: true });
|
|
4044
|
+
case "start": {
|
|
4045
|
+
const pid = await daemon.ensureDaemon();
|
|
4046
|
+
process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: failed to start\n");
|
|
4047
|
+
return pid ? 0 : 1;
|
|
4048
|
+
}
|
|
4049
|
+
case "status": {
|
|
4050
|
+
const pid = await daemon.daemonStatus();
|
|
4051
|
+
process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: not running\n");
|
|
4052
|
+
return pid ? 0 : 1;
|
|
4053
|
+
}
|
|
4054
|
+
case "stop": {
|
|
4055
|
+
const pid = await daemon.requestDaemonStop();
|
|
4056
|
+
process.stdout.write(pid ? `notifyd: stop requested (pid ${pid} will exit shortly)\n` : "notifyd: not running\n");
|
|
4057
|
+
return 0;
|
|
4058
|
+
}
|
|
4059
|
+
default:
|
|
4060
|
+
process.stderr.write("usage: ay notifyd <run|once|start|status|stop>\n");
|
|
4061
|
+
return 1;
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
3059
4064
|
|
|
3060
4065
|
//#endregion
|
|
3061
|
-
export {
|
|
3062
|
-
//# sourceMappingURL=subcommands-
|
|
4066
|
+
export { resolveOne as A, writeKeysPaced as B, matchKeyword as C, renderLogTailLines as D, readPtysize as E, snapshotStatus as F, hostId as G, TYPING_BADGE as H, stdinActivityPath as I, readInbox as J, listInboxParents as K, stopTipForCli as L, resolveResumeArgs as M, restartHintLines as N, renderRawLog as O, runSubcommand as P, notifyDir as Q, submitAndConfirm as R, listRecords as S, readNotes as T, appendEvent as U, writeToIpc as V, gcInboxes as W, daemonLockDir as X, shouldStealLock as Y, daemonLockOwnerPath as Z, isPidAlive as _, cmdHelp as a, isUserTyping as b, deriveLiveState as c, extractMenu as d, extractNeedsInput as f, isExitRequest as g, isAgentStuck as h, backoffWhileTyping as i, resolveReadWindow as j, renderRawLogLines as k, deriveLiveStatus as l, finalizedLines as m, READ_PAGE_DEFAULT as n, controlCodeFromName as o, extractTaskCounts as p, liveWatchers as q, TYPING_WINDOW_MS as r, cursorAbs as s, GRACEFUL_EXIT_COMMANDS as t, extractBadges as u, isSlashCommand as v, menuSelectKeys as w, lastStdinAt as x, isSubcommand as y, waitForLogQuiet as z };
|
|
4067
|
+
//# sourceMappingURL=subcommands-BFlpV23b.js.map
|