agent-yes 1.175.1 → 1.176.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/agent-yes.config.schema.json +2 -2
- package/default.config.yaml +45 -0
- package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
- package/dist/{SUPPORTED_CLIS-DH7pORiR.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
- package/dist/agentShare-DRfghQz8.js +231 -0
- package/dist/bash-yes.js +10 -0
- package/dist/cli.js +8 -8
- package/dist/cmd-yes.js +10 -0
- package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
- package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-CLRNmdHy.js → notifyDaemon-C08fJ2Ai.js} +5 -5
- package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
- package/dist/powershell-yes.js +10 -0
- package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
- package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
- package/dist/{rustBinary-DSN5ekb5.js → rustBinary-DZTU07SA.js} +2 -2
- package/dist/{schedule-DXGDI0Uo.js → schedule-BQtFOk0z.js} +5 -5
- package/dist/{serve-DgQjFYQC.js → serve-B5PQdiOb.js} +143 -76
- package/dist/{setup-DHVydU-j.js → setup-BCqFNJoB.js} +3 -3
- package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
- package/dist/share-nvqy64A2.js +4 -0
- package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
- package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
- package/dist/{subcommands-DjA3FgJk.js → subcommands-BbSJ_nTU.js} +736 -714
- package/dist/subcommands-BokdtqqC.js +9 -0
- package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
- package/dist/{ts-PerHq0yJ.js → ts-Ce7a44Rj.js} +3 -3
- package/dist/{versionChecker-CfZx87hW.js → versionChecker-C7C7QAPu.js} +2 -2
- package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
- package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
- package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
- package/lab/ui/index.html +495 -291
- package/lab/ui/qrcode.js +2297 -0
- package/lab/ui/sw.js +2 -1
- package/package.json +8 -2
- package/scripts/build-rgui.ts +78 -0
- package/ts/agentShare.lifecycle.spec.ts +139 -0
- package/ts/agentShare.spec.ts +238 -0
- package/ts/agentShare.ts +330 -0
- package/ts/forkNested.spec.ts +39 -2
- package/ts/index.ts +5 -1
- package/ts/serve.ts +119 -5
- package/ts/share.ts +10 -3
- package/ts/subcommands.ts +29 -0
- package/dist/SUPPORTED_CLIS-COSdc1BZ.js +0 -8
- package/dist/subcommands-C9u1em-X.js +0 -9
|
@@ -3,8 +3,8 @@ import { n as logger } from "./logger-CDIsZ-Pp.js";
|
|
|
3
3
|
import { t as agentYesHome } from "./agentYesHome-CtHb5b71.js";
|
|
4
4
|
import { a as updateGlobalPidStatus, i as readGlobalPids } from "./globalPidIndex-CoNr7tS8.js";
|
|
5
5
|
import { t as loadSharedCliDefaults } from "./configShared-0MnIQ652.js";
|
|
6
|
-
import { n as isWebrtcSpec } from "./webrtcLink-
|
|
7
|
-
import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-
|
|
6
|
+
import { n as isWebrtcSpec } from "./webrtcLink-BG0Xc4-W.js";
|
|
7
|
+
import { a as resolveRemoteSpec, i as readRemotes } from "./remotes-DsTnQhyx.js";
|
|
8
8
|
import ms from "ms";
|
|
9
9
|
import yargs from "yargs";
|
|
10
10
|
import { appendFile, mkdir, open, readFile, readdir, rename, rm, stat, writeFile } from "fs/promises";
|
|
@@ -13,798 +13,798 @@ import path from "path";
|
|
|
13
13
|
import { randomUUID } from "node:crypto";
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
|
|
16
|
-
//#region 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
|
-
}
|
|
16
|
+
//#region ts/agentTree.ts
|
|
97
17
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
18
|
+
* Link records into a forest via parent_pid === wrapper_pid. Records whose
|
|
19
|
+
* parent isn't present in the set (top-level agents, or links into agents
|
|
20
|
+
* filtered out by a keyword/scope) become roots. Root and sibling order follows
|
|
21
|
+
* the input order, so a caller that pre-sorts (e.g. newest-first) is preserved.
|
|
102
22
|
*/
|
|
103
|
-
function
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
23
|
+
function buildAgentForest(records) {
|
|
24
|
+
const nodes = records.map((record) => ({
|
|
25
|
+
record,
|
|
26
|
+
children: []
|
|
27
|
+
}));
|
|
28
|
+
const byWrapper = /* @__PURE__ */ new Map();
|
|
29
|
+
for (const n of nodes) {
|
|
30
|
+
const w = n.record.wrapper_pid;
|
|
31
|
+
if (typeof w === "number" && w > 0) byWrapper.set(w, n);
|
|
112
32
|
}
|
|
113
|
-
|
|
33
|
+
const roots = [];
|
|
34
|
+
for (const n of nodes) {
|
|
35
|
+
const p = n.record.parent_pid;
|
|
36
|
+
const parent = typeof p === "number" && p > 0 ? byWrapper.get(p) : void 0;
|
|
37
|
+
if (parent && parent !== n) parent.children.push(n);
|
|
38
|
+
else roots.push(n);
|
|
39
|
+
}
|
|
40
|
+
const seen = /* @__PURE__ */ new Set();
|
|
41
|
+
const mark = (n) => {
|
|
42
|
+
if (seen.has(n)) return;
|
|
43
|
+
seen.add(n);
|
|
44
|
+
n.children.forEach(mark);
|
|
45
|
+
};
|
|
46
|
+
roots.forEach(mark);
|
|
47
|
+
for (const n of nodes) if (!seen.has(n)) roots.push(n);
|
|
48
|
+
return roots;
|
|
114
49
|
}
|
|
115
|
-
const EDGES = new Set([
|
|
116
|
-
"needs_input",
|
|
117
|
-
"idle",
|
|
118
|
-
"exited"
|
|
119
|
-
]);
|
|
120
50
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
51
|
+
* Depth-first flatten a forest into rows carrying a box-drawing branch prefix.
|
|
52
|
+
* A `visited` guard makes a pathological parent_pid cycle terminate instead of
|
|
53
|
+
* recursing forever.
|
|
124
54
|
*/
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
55
|
+
function flattenForest(roots) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
const visited = /* @__PURE__ */ new Set();
|
|
58
|
+
const walk = (node, ancestorsLast) => {
|
|
59
|
+
if (visited.has(node)) return;
|
|
60
|
+
visited.add(node);
|
|
61
|
+
const depth = ancestorsLast.length;
|
|
62
|
+
let prefix = "";
|
|
63
|
+
for (let i = 0; i < depth - 1; i++) prefix += ancestorsLast[i] ? " " : "│ ";
|
|
64
|
+
if (depth > 0) prefix += ancestorsLast[depth - 1] ? "└─ " : "├─ ";
|
|
65
|
+
rows.push({
|
|
66
|
+
record: node.record,
|
|
67
|
+
prefix,
|
|
68
|
+
depth
|
|
69
|
+
});
|
|
70
|
+
node.children.forEach((c, i) => walk(c, [...ancestorsLast, i === node.children.length - 1]));
|
|
71
|
+
};
|
|
72
|
+
for (const r of roots) walk(r, []);
|
|
73
|
+
return rows;
|
|
139
74
|
}
|
|
75
|
+
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region ts/todoParse.ts
|
|
140
78
|
/**
|
|
141
|
-
*
|
|
142
|
-
*
|
|
79
|
+
* Parse an agent's todo/task list out of its RENDERED TUI screen.
|
|
80
|
+
*
|
|
81
|
+
* Source of truth for EVERY CLI (claude, codex, gemini, …) is the screen the
|
|
82
|
+
* agent draws — never a CLI-specific session file. The durable copy is the
|
|
83
|
+
* per-pid raw log (`<cwd>/.agent-yes/<pid>.raw.log`); rendering it through a
|
|
84
|
+
* headless xterm (see renderRawLog) collapses the reflow/redraw frames into the
|
|
85
|
+
* final coherent text, which is what we scan here.
|
|
86
|
+
*
|
|
87
|
+
* The todo list in these TUIs renders as a tree block anchored by the `⎿`
|
|
88
|
+
* branch glyph, one marker per line:
|
|
89
|
+
*
|
|
90
|
+
* ⎿ ☒ Wire up the parser
|
|
91
|
+
* ☒ Add the badge
|
|
92
|
+
* ◼ Compute in /api/ls ← in progress
|
|
93
|
+
* ◻ Render in the console ← pending
|
|
94
|
+
* ◻ Tests
|
|
95
|
+
*
|
|
96
|
+
* Badge = `${done}/${total}` (done is the numerator → "2/5").
|
|
97
|
+
*
|
|
98
|
+
* This parse is deliberately conservative: we only report a count when a block
|
|
99
|
+
* is confidently detected (the `⎿` anchor + ≥2 consecutive marker lines), so an
|
|
100
|
+
* agent that merely prints a check glyph in prose never produces a phantom badge.
|
|
143
101
|
*/
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
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)) });
|
|
102
|
+
const DONE = new Set([
|
|
103
|
+
"✔",
|
|
104
|
+
"☑",
|
|
105
|
+
"✓",
|
|
106
|
+
"☒"
|
|
107
|
+
]);
|
|
108
|
+
const IN_PROGRESS = new Set(["◼"]);
|
|
109
|
+
const PENDING = new Set(["◻", "☐"]);
|
|
110
|
+
const ANCHOR = "⎿";
|
|
111
|
+
function markerOf(line) {
|
|
112
|
+
let s = line.replace(/^\s+/, "");
|
|
113
|
+
if (s.startsWith(ANCHOR)) s = s.slice(1).replace(/^\s+/, "");
|
|
114
|
+
const ch = [...s][0];
|
|
115
|
+
if (ch === void 0) return null;
|
|
116
|
+
if (DONE.has(ch)) return "done";
|
|
117
|
+
if (IN_PROGRESS.has(ch)) return "inprogress";
|
|
118
|
+
if (PENDING.has(ch)) return "pending";
|
|
119
|
+
return null;
|
|
169
120
|
}
|
|
170
121
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
122
|
+
* Find the MOST RECENT confidently-detected todo block in the rendered lines and
|
|
123
|
+
* return its {done, total}. Returns null when none qualifies (caller omits the
|
|
124
|
+
* badge entirely — never shows "0/0").
|
|
125
|
+
*
|
|
126
|
+
* A block is a maximal run of consecutive marker lines. It only counts when it
|
|
127
|
+
* is anchored — the `⎿` glyph appears on the run's first line or the line
|
|
128
|
+
* directly above it — and has ≥2 marker lines. The last qualifying block wins,
|
|
129
|
+
* since the agent's current todo state is the one drawn most recently.
|
|
177
130
|
*/
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
let
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
131
|
+
function parseTaskCounts(lines) {
|
|
132
|
+
let best = null;
|
|
133
|
+
const n = lines.length;
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < n) {
|
|
136
|
+
if (markerOf(lines[i]) === null) {
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let hasAnchor = i > 0 && lines[i - 1].includes(ANCHOR);
|
|
141
|
+
const counts = {
|
|
142
|
+
done: 0,
|
|
143
|
+
inprogress: 0,
|
|
144
|
+
pending: 0
|
|
145
|
+
};
|
|
146
|
+
let j = i;
|
|
147
|
+
for (; j < n; j++) {
|
|
148
|
+
const mk = markerOf(lines[j]);
|
|
149
|
+
if (mk === null) break;
|
|
150
|
+
if (lines[j].includes(ANCHOR)) hasAnchor = true;
|
|
151
|
+
counts[mk]++;
|
|
152
|
+
}
|
|
153
|
+
const total = counts.done + counts.inprogress + counts.pending;
|
|
154
|
+
if (hasAnchor && total >= 2) best = {
|
|
155
|
+
done: counts.done,
|
|
156
|
+
total
|
|
157
|
+
};
|
|
158
|
+
i = j === i ? i + 1 : j;
|
|
188
159
|
}
|
|
189
|
-
|
|
190
|
-
return kept;
|
|
160
|
+
return best;
|
|
191
161
|
}
|
|
192
162
|
|
|
193
163
|
//#endregion
|
|
194
|
-
//#region ts/
|
|
164
|
+
//#region ts/badges.ts
|
|
165
|
+
const BADGE_DEFS = [
|
|
166
|
+
{
|
|
167
|
+
id: "goal-active",
|
|
168
|
+
label: "goal",
|
|
169
|
+
title: "A /goal Stop-hook loop is active on this agent",
|
|
170
|
+
pattern: /\/goal active/i
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: "session-limit",
|
|
174
|
+
label: "limit",
|
|
175
|
+
title: "Usage session limit hit — waiting for the reset time shown on screen",
|
|
176
|
+
pattern: /you['’]?ve hit your session limit/i
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "retrying",
|
|
180
|
+
label: "retry",
|
|
181
|
+
title: "Waiting for the API — the CLI is auto-retrying on its own backoff (no action needed)",
|
|
182
|
+
pattern: /Waiting for API response[\s\S]{0,40}will retry in \d/i
|
|
183
|
+
}
|
|
184
|
+
];
|
|
195
185
|
/**
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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.
|
|
186
|
+
* Which badge ids match the given rendered screen lines. Pure so it's
|
|
187
|
+
* unit-tested without a live PTY/log file.
|
|
204
188
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
return
|
|
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
|
-
}
|
|
189
|
+
function matchBadges(lines, defs = BADGE_DEFS) {
|
|
190
|
+
const text = lines.join("\n");
|
|
191
|
+
return defs.filter((d) => d.pattern.test(text)).map((d) => d.id);
|
|
217
192
|
}
|
|
218
193
|
/**
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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.
|
|
194
|
+
* A time-derived flag (NOT screen-matched): lit when the user typed at this
|
|
195
|
+
* agent's terminal within the last few seconds. The ls code appends its id from
|
|
196
|
+
* the Rust runner's stdin-activity marker, so it resolves through `badgeDef`
|
|
197
|
+
* like any other chip — but `matchBadges` never produces it (it isn't in
|
|
198
|
+
* BADGE_DEFS, and its pattern can't match), keeping screen matching pure.
|
|
229
199
|
*/
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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;
|
|
200
|
+
const TYPING_BADGE = {
|
|
201
|
+
id: "typing",
|
|
202
|
+
label: "typing",
|
|
203
|
+
title: "The user is typing at this agent's terminal — ay send backs off until they pause",
|
|
204
|
+
pattern: /(?!)/
|
|
205
|
+
};
|
|
206
|
+
function badgeDef(id, defs = BADGE_DEFS) {
|
|
207
|
+
return defs.find((d) => d.id === id) ?? (id === TYPING_BADGE.id ? TYPING_BADGE : void 0);
|
|
245
208
|
}
|
|
246
|
-
|
|
247
|
-
|
|
209
|
+
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region ts/needsInput.ts
|
|
212
|
+
function reTest(re, s) {
|
|
213
|
+
return (re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re).test(s);
|
|
214
|
+
}
|
|
215
|
+
function isChromeLine(s) {
|
|
216
|
+
const t = s.trim();
|
|
217
|
+
return !t || /^─+$/.test(t) || /^esc to (interrupt|cancel)/i.test(t) || /\? for shortcuts/.test(t) || /\d+%\s*until auto-compact/i.test(t);
|
|
248
218
|
}
|
|
249
219
|
/**
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
220
|
+
* Returns a NeedsInput when the screen shows an unresolved selection menu, else
|
|
221
|
+
* null. `cfg.working` short-circuits to null (an actively-working agent isn't
|
|
222
|
+
* blocked). Pure + synchronous so it's trivially unit-testable.
|
|
253
223
|
*/
|
|
254
|
-
|
|
255
|
-
const
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
224
|
+
function classifyNeedsInput(lines, cfg) {
|
|
225
|
+
const patterns = cfg.needsInput ?? [];
|
|
226
|
+
if (patterns.length === 0) return null;
|
|
227
|
+
const text = lines.join("\n");
|
|
228
|
+
if ((cfg.working ?? []).some((re) => reTest(re, text))) return null;
|
|
229
|
+
if (!patterns.some((re) => reTest(re, text))) return null;
|
|
230
|
+
let last = -1;
|
|
231
|
+
for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) last = i;
|
|
232
|
+
const start = Math.max(0, last - 6);
|
|
233
|
+
const end = Math.min(lines.length, last + 6);
|
|
234
|
+
return { question: lines.slice(start, end).map((l) => l.trim()).filter((l) => l && !isChromeLine(l)).join(" • ").slice(0, 400) };
|
|
264
235
|
}
|
|
236
|
+
const OPTION_LINE = /^[\s❯›>▶◉○●·*-]*?(\d+)\.\s/;
|
|
265
237
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
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.
|
|
238
|
+
* Parse the selection menu a `needs_input` agent is parked on into a cursor
|
|
239
|
+
* position + the available option numbers, so a caller can compute how far the
|
|
240
|
+
* cursor must move (Down/Up) to reach a target option. Returns null when the
|
|
241
|
+
* screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
|
|
242
|
+
* so `working` still wins) or no numbered cursor line is found. Pure — the
|
|
243
|
+
* `ay select` action reuses the exact detection `ay ls` renders with.
|
|
276
244
|
*/
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
245
|
+
function parseMenu(lines, cfg) {
|
|
246
|
+
const ni = classifyNeedsInput(lines, cfg);
|
|
247
|
+
if (!ni) return null;
|
|
248
|
+
const patterns = cfg.needsInput ?? [];
|
|
249
|
+
let cursorLine = -1;
|
|
250
|
+
for (let i = 0; i < lines.length; i++) if (patterns.some((re) => reTest(re, lines[i]))) cursorLine = i;
|
|
251
|
+
if (cursorLine < 0) return null;
|
|
252
|
+
const cm = /(\d+)\./.exec(lines[cursorLine]);
|
|
253
|
+
if (!cm) return null;
|
|
254
|
+
const cursor = parseInt(cm[1], 10);
|
|
255
|
+
const start = Math.max(0, cursorLine - 12);
|
|
256
|
+
const end = Math.min(lines.length, cursorLine + 12);
|
|
257
|
+
const options = [];
|
|
258
|
+
for (let i = start; i < end; i++) {
|
|
259
|
+
const m = OPTION_LINE.exec(lines[i]);
|
|
260
|
+
if (m) {
|
|
261
|
+
const v = parseInt(m[1], 10);
|
|
262
|
+
if (!options.includes(v)) options.push(v);
|
|
285
263
|
}
|
|
264
|
+
}
|
|
265
|
+
if (!options.includes(cursor)) options.push(cursor);
|
|
266
|
+
options.sort((a, b) => a - b);
|
|
267
|
+
return {
|
|
268
|
+
cursor,
|
|
269
|
+
options,
|
|
270
|
+
question: ni.question
|
|
286
271
|
};
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* True when the rendered screen still shows a "busy" marker (config `working`,
|
|
275
|
+
* e.g. claude's `esc to interrupt`). Paired with a long-quiet log this is the
|
|
276
|
+
* `stuck` signal: a live spinner writes to the log every frame, so a busy marker
|
|
277
|
+
* on screen WITHOUT recent output means the agent wedged mid-stream (a silent
|
|
278
|
+
* API stream stall) rather than finishing. Pure + synchronous like the rest of
|
|
279
|
+
* this module so it's trivially unit-testable.
|
|
280
|
+
*/
|
|
281
|
+
function isWorkingScreen(lines, working) {
|
|
282
|
+
if (!working?.length) return false;
|
|
283
|
+
const text = lines.join("\n");
|
|
284
|
+
return working.some((re) => reTest(re, text));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region ts/lsWatch.ts
|
|
289
|
+
/**
|
|
290
|
+
* Diff the previous per-pid states against the current snapshot and return the
|
|
291
|
+
* transition events to emit plus the next prev-map. Pure: no I/O, no clock —
|
|
292
|
+
* the caller passes `ts`.
|
|
293
|
+
*
|
|
294
|
+
* Emits an event when:
|
|
295
|
+
* - an agent is seen for the first time (baseline, `prev_state: null`)
|
|
296
|
+
* - its `state` or `question` changed since last tick
|
|
297
|
+
* - it vanished from the live set without us ever seeing it `stopped` (reaped
|
|
298
|
+
* between ticks) — a synthetic `stopped` event so a "done" transition is
|
|
299
|
+
* never silently dropped.
|
|
300
|
+
*/
|
|
301
|
+
function diffLsStates(prev, cur, ts) {
|
|
302
|
+
const events = [];
|
|
303
|
+
const next = /* @__PURE__ */ new Map();
|
|
304
|
+
const curPids = /* @__PURE__ */ new Set();
|
|
305
|
+
for (const a of cur) {
|
|
306
|
+
curPids.add(a.pid);
|
|
307
|
+
next.set(a.pid, a);
|
|
308
|
+
const p = prev.get(a.pid);
|
|
309
|
+
if (!p) events.push({
|
|
310
|
+
...toEvent(a, ts),
|
|
311
|
+
prev_state: null
|
|
312
|
+
});
|
|
313
|
+
else if (p.state !== a.state || p.question !== a.question) events.push({
|
|
314
|
+
...toEvent(a, ts),
|
|
315
|
+
prev_state: p.state
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
for (const [pid, p] of prev) if (!curPids.has(pid) && p.state !== "stopped") events.push({
|
|
319
|
+
ts,
|
|
320
|
+
pid,
|
|
321
|
+
cli: p.cli,
|
|
322
|
+
cwd: p.cwd,
|
|
323
|
+
state: "stopped",
|
|
324
|
+
question: null,
|
|
325
|
+
prev_state: p.state
|
|
326
|
+
});
|
|
327
|
+
return {
|
|
328
|
+
events,
|
|
329
|
+
next
|
|
301
330
|
};
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
331
|
+
}
|
|
332
|
+
function toEvent(a, ts) {
|
|
333
|
+
return {
|
|
334
|
+
ts,
|
|
335
|
+
pid: a.pid,
|
|
336
|
+
cli: a.cli,
|
|
337
|
+
cwd: a.cwd,
|
|
338
|
+
state: a.state,
|
|
339
|
+
question: a.question
|
|
305
340
|
};
|
|
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
341
|
}
|
|
342
|
+
|
|
343
|
+
//#endregion
|
|
344
|
+
//#region ts/notifyInbox.ts
|
|
350
345
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
346
|
+
* Pure, fs-free core for the subagent→parent notification inbox — the storage
|
|
347
|
+
* and watermark layer under `ay notify` / `ay notifyd`.
|
|
348
|
+
*
|
|
349
|
+
* Motivation (real incident): a parent agent fanned out sub-agents that finished
|
|
350
|
+
* their work but sat at an idle `❯` prompt WITHOUT exiting (claude-yes does not
|
|
351
|
+
* exit on idle). Claude Code's background-task notification only fires on process
|
|
352
|
+
* EXIT, so the parent never learned the children went idle and left them parked
|
|
353
|
+
* 16 minutes. `ay ls --watch` already streams transitions, but it is PULL: the
|
|
354
|
+
* parent must run the watch loop. The whole point of this pain is the parent is
|
|
355
|
+
* NOT watching. So we accumulate qualifying edges into a per-parent append-only
|
|
356
|
+
* inbox that the parent drains on its own schedule (its Monitor loop), with a
|
|
357
|
+
* persisted cursor so a parent that restarts reads only the unread edges.
|
|
358
|
+
*
|
|
359
|
+
* This module is the pure part (path math, NDJSON (de)serialization, seq/cursor
|
|
360
|
+
* filtering, retention decisions) so it is trivially unit-testable, mirroring
|
|
361
|
+
* `lsWatch.ts` / `needsInput.ts` / `resultEnvelope.ts`. The fs read/write + lock
|
|
362
|
+
* + CLI live in `subcommands.ts`; the detection/debounce lives in
|
|
363
|
+
* `notifyRouter.ts`.
|
|
353
364
|
*/
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
}
|
|
365
|
+
/** Root dir for all notification state. */
|
|
366
|
+
function notifyDir() {
|
|
367
|
+
return path.join(agentYesHome(), "notify");
|
|
368
|
+
}
|
|
369
|
+
/** Per-host inbox directory (pids are only unique within a host). */
|
|
370
|
+
function inboxDir(host) {
|
|
371
|
+
return path.join(notifyDir(), "inbox", sanitizeHost(host));
|
|
376
372
|
}
|
|
377
|
-
/**
|
|
378
|
-
|
|
379
|
-
return
|
|
373
|
+
/** Absolute path of one parent's append-only NDJSON inbox. */
|
|
374
|
+
function inboxPath(host, parentPid) {
|
|
375
|
+
return path.join(inboxDir(host), `${parentPid}.ndjson`);
|
|
380
376
|
}
|
|
381
|
-
/**
|
|
382
|
-
|
|
383
|
-
|
|
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;
|
|
377
|
+
/** Sidecar seq counter for a parent inbox (last allocated seq). */
|
|
378
|
+
function seqPath(host, parentPid) {
|
|
379
|
+
return path.join(inboxDir(host), `${parentPid}.seq`);
|
|
390
380
|
}
|
|
391
|
-
|
|
392
|
-
|
|
381
|
+
/** Per-consumer cursor directory for a parent inbox. */
|
|
382
|
+
function cursorDir(host, parentPid) {
|
|
383
|
+
return path.join(notifyDir(), "cursors", sanitizeHost(host), String(parentPid));
|
|
393
384
|
}
|
|
394
|
-
/**
|
|
395
|
-
|
|
396
|
-
|
|
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;
|
|
385
|
+
/** Absolute path of one consumer's cursor file for a parent inbox. */
|
|
386
|
+
function cursorPath(host, parentPid, consumer = "parent") {
|
|
387
|
+
return path.join(cursorDir(host, parentPid), `${sanitizeConsumer(consumer)}.json`);
|
|
407
388
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
await atomicWrite(p, serializeCursor(seq));
|
|
389
|
+
/** The daemon singleton lock dir (mkdir-based, like pidStore). */
|
|
390
|
+
function daemonLockDir() {
|
|
391
|
+
return path.join(notifyDir(), "notifyd.lock");
|
|
412
392
|
}
|
|
413
393
|
/**
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
394
|
+
* Owner metadata inside the lock dir ({pid, started_at, ts}) — the SINGLE source
|
|
395
|
+
* of truth for the running daemon's identity, used for stale-lock steal and for
|
|
396
|
+
* `notifyd status`/`stop` (a running daemon refreshes `ts` each tick).
|
|
417
397
|
*/
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
}
|
|
398
|
+
function daemonLockOwnerPath() {
|
|
399
|
+
return path.join(daemonLockDir(), "owner.json");
|
|
442
400
|
}
|
|
443
|
-
/**
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
await ensureDir(watchersDir());
|
|
447
|
-
await atomicWrite(watcherPath(parentPid), JSON.stringify({
|
|
448
|
-
pid: parentPid,
|
|
449
|
-
started_at: startedAt,
|
|
450
|
-
ts: Date.now()
|
|
451
|
-
}));
|
|
401
|
+
/** Registry dir of parents currently running `ay notify watch` (heartbeats). */
|
|
402
|
+
function watchersDir() {
|
|
403
|
+
return path.join(notifyDir(), "watchers");
|
|
452
404
|
}
|
|
453
|
-
/**
|
|
454
|
-
|
|
455
|
-
|
|
405
|
+
/** Heartbeat file for one watching parent. */
|
|
406
|
+
function watcherPath(parentPid) {
|
|
407
|
+
return path.join(watchersDir(), `${parentPid}.json`);
|
|
408
|
+
}
|
|
409
|
+
/** A watcher heartbeat is "live" if refreshed within this window. */
|
|
410
|
+
const WATCHER_TTL_MS = 15e3;
|
|
411
|
+
function sanitizeSegment(s, fallback) {
|
|
412
|
+
const cleaned = s.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
413
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? fallback : cleaned;
|
|
414
|
+
}
|
|
415
|
+
function sanitizeHost(host) {
|
|
416
|
+
return sanitizeSegment(host, "localhost");
|
|
417
|
+
}
|
|
418
|
+
function sanitizeConsumer(consumer) {
|
|
419
|
+
return sanitizeSegment(consumer, "parent");
|
|
420
|
+
}
|
|
421
|
+
/** Serialize one event to its NDJSON line (no trailing newline). */
|
|
422
|
+
function serializeEvent(ev) {
|
|
423
|
+
return JSON.stringify(ev);
|
|
456
424
|
}
|
|
457
425
|
/**
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
*
|
|
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.
|
|
426
|
+
* Parse an inbox's raw text into events. Tolerant by design: a torn final line
|
|
427
|
+
* (a writer mid-append) or any unparseable line is skipped, never throws — a
|
|
428
|
+
* reader must survive a concurrent writer. Order is file order (= seq order,
|
|
429
|
+
* since a single locked writer appends in increasing seq).
|
|
467
430
|
*/
|
|
468
|
-
|
|
469
|
-
const
|
|
470
|
-
const
|
|
471
|
-
|
|
472
|
-
if (!
|
|
473
|
-
const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
|
|
431
|
+
function parseInboxText(text) {
|
|
432
|
+
const out = [];
|
|
433
|
+
for (const line of text.split("\n")) {
|
|
434
|
+
const t = line.trim();
|
|
435
|
+
if (!t) continue;
|
|
474
436
|
try {
|
|
475
|
-
const
|
|
476
|
-
if (
|
|
437
|
+
const ev = JSON.parse(t);
|
|
438
|
+
if (isValidEvent(ev)) out.push(ev);
|
|
477
439
|
} catch {}
|
|
478
440
|
}
|
|
479
|
-
return
|
|
441
|
+
return out;
|
|
480
442
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
443
|
+
const EDGES = new Set([
|
|
444
|
+
"needs_input",
|
|
445
|
+
"idle",
|
|
446
|
+
"exited"
|
|
447
|
+
]);
|
|
484
448
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* the input order, so a caller that pre-sorts (e.g. newest-first) is preserved.
|
|
449
|
+
* Full shape validation at the storage boundary — a torn/partial line missing
|
|
450
|
+
* the correlation fields must never flow through to a consumer's output. Every
|
|
451
|
+
* event we surface has the fields a reader relies on.
|
|
489
452
|
*/
|
|
490
|
-
function
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
453
|
+
function isValidEvent(ev) {
|
|
454
|
+
if (!ev || typeof ev !== "object") return false;
|
|
455
|
+
const e = ev;
|
|
456
|
+
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");
|
|
457
|
+
}
|
|
458
|
+
/** Next seq to allocate given the last stored seq counter (or the inbox max). */
|
|
459
|
+
function nextSeq(lastSeq) {
|
|
460
|
+
return (Number.isFinite(lastSeq) && lastSeq > 0 ? lastSeq : 0) + 1;
|
|
461
|
+
}
|
|
462
|
+
/** Highest seq present in a parsed inbox (0 when empty). */
|
|
463
|
+
function maxSeq(events) {
|
|
464
|
+
let m = 0;
|
|
465
|
+
for (const e of events) if (e.seq > m) m = e.seq;
|
|
466
|
+
return m;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Filter events for `--since <seq>`: strictly-greater than the given seq. A
|
|
470
|
+
* `since` of 0/undefined returns everything.
|
|
471
|
+
*/
|
|
472
|
+
function filterSinceSeq(events, sinceSeq) {
|
|
473
|
+
if (!sinceSeq || sinceSeq <= 0) return events.slice();
|
|
474
|
+
return events.filter((e) => e.seq > sinceSeq);
|
|
475
|
+
}
|
|
476
|
+
/** Filter events after a wall-clock `--since <ts>` (inclusive lower bound). */
|
|
477
|
+
function filterSinceTs(events, sinceTs) {
|
|
478
|
+
if (!sinceTs || sinceTs <= 0) return events.slice();
|
|
479
|
+
return events.filter((e) => e.ts >= sinceTs);
|
|
480
|
+
}
|
|
481
|
+
/** Unread = seq strictly greater than the consumer's cursor. */
|
|
482
|
+
function filterUnread(events, cursorSeq) {
|
|
483
|
+
return events.filter((e) => e.seq > (cursorSeq > 0 ? cursorSeq : 0));
|
|
484
|
+
}
|
|
485
|
+
/** Parse a cursor file's text; a missing/garbage cursor reads as seq 0. */
|
|
486
|
+
function parseCursor(text) {
|
|
487
|
+
if (!text) return { seq: 0 };
|
|
488
|
+
try {
|
|
489
|
+
const c = JSON.parse(text);
|
|
490
|
+
return { seq: Number.isFinite(c?.seq) && c.seq > 0 ? c.seq : 0 };
|
|
491
|
+
} catch {
|
|
492
|
+
return { seq: 0 };
|
|
506
493
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
seen.add(n);
|
|
511
|
-
n.children.forEach(mark);
|
|
512
|
-
};
|
|
513
|
-
roots.forEach(mark);
|
|
514
|
-
for (const n of nodes) if (!seen.has(n)) roots.push(n);
|
|
515
|
-
return roots;
|
|
494
|
+
}
|
|
495
|
+
function serializeCursor(seq) {
|
|
496
|
+
return JSON.stringify({ seq: Math.max(0, Math.floor(seq)) });
|
|
516
497
|
}
|
|
517
498
|
/**
|
|
518
|
-
*
|
|
519
|
-
*
|
|
520
|
-
*
|
|
499
|
+
* Rotation decision (pure): given an inbox's events, a byte cap, and the minimum
|
|
500
|
+
* un-acked cursor across ALL consumers, return the events to KEEP after
|
|
501
|
+
* rotating. Critically, an event with `seq > protectAboveSeq` is NEVER dropped —
|
|
502
|
+
* that is the at-least-once guarantee: we must not evict an edge no consumer has
|
|
503
|
+
* acknowledged yet, even under the byte cap. Below that watermark we keep the
|
|
504
|
+
* newest events that fit the cap (and at least `minKeep`).
|
|
521
505
|
*/
|
|
522
|
-
function
|
|
523
|
-
|
|
524
|
-
const
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
depth
|
|
536
|
-
});
|
|
537
|
-
node.children.forEach((c, i) => walk(c, [...ancestorsLast, i === node.children.length - 1]));
|
|
538
|
-
};
|
|
539
|
-
for (const r of roots) walk(r, []);
|
|
540
|
-
return rows;
|
|
506
|
+
function rotateKeep(events, capBytes, protectAboveSeq = 0, minKeep = 100) {
|
|
507
|
+
if (events.length <= minKeep) return events.slice();
|
|
508
|
+
const kept = [];
|
|
509
|
+
let bytes = 0;
|
|
510
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
511
|
+
const e = events[i];
|
|
512
|
+
const line = serializeEvent(e) + "\n";
|
|
513
|
+
bytes += line.length;
|
|
514
|
+
if (e.seq > protectAboveSeq || kept.length < minKeep || bytes <= capBytes) kept.push(e);
|
|
515
|
+
else break;
|
|
516
|
+
}
|
|
517
|
+
kept.reverse();
|
|
518
|
+
return kept;
|
|
541
519
|
}
|
|
542
520
|
|
|
543
521
|
//#endregion
|
|
544
|
-
//#region ts/
|
|
522
|
+
//#region ts/notifyStore.ts
|
|
545
523
|
/**
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
549
|
-
*
|
|
550
|
-
* per-pid raw log (`<cwd>/.agent-yes/<pid>.raw.log`); rendering it through a
|
|
551
|
-
* headless xterm (see renderRawLog) collapses the reflow/redraw frames into the
|
|
552
|
-
* final coherent text, which is what we scan here.
|
|
553
|
-
*
|
|
554
|
-
* The todo list in these TUIs renders as a tree block anchored by the `⎿`
|
|
555
|
-
* branch glyph, one marker per line:
|
|
556
|
-
*
|
|
557
|
-
* ⎿ ☒ Wire up the parser
|
|
558
|
-
* ☒ Add the badge
|
|
559
|
-
* ◼ Compute in /api/ls ← in progress
|
|
560
|
-
* ◻ Render in the console ← pending
|
|
561
|
-
* ◻ Tests
|
|
562
|
-
*
|
|
563
|
-
* Badge = `${done}/${total}` (done is the numerator → "2/5").
|
|
524
|
+
* Filesystem side of the notification inbox — the impure half of the pure
|
|
525
|
+
* `notifyInbox.ts` path/serialization core. Handles the locked append (so
|
|
526
|
+
* concurrent daemon ticks / future writers never interleave a seq), inbox and
|
|
527
|
+
* cursor reads/writes, and retention GC.
|
|
564
528
|
*
|
|
565
|
-
*
|
|
566
|
-
*
|
|
567
|
-
*
|
|
529
|
+
* The lock is a mkdir-based advisory lock (same primitive `pidStore` uses), held
|
|
530
|
+
* only for the microseconds of a read-counter → append-line → write-counter, so
|
|
531
|
+
* contention is negligible even with many parents.
|
|
568
532
|
*/
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
"
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
const ch = [...s][0];
|
|
582
|
-
if (ch === void 0) return null;
|
|
583
|
-
if (DONE.has(ch)) return "done";
|
|
584
|
-
if (IN_PROGRESS.has(ch)) return "inprogress";
|
|
585
|
-
if (PENDING.has(ch)) return "pending";
|
|
586
|
-
return null;
|
|
533
|
+
/** Stable local host id — the pid namespace is per-host. */
|
|
534
|
+
function hostId() {
|
|
535
|
+
return os.hostname() || "localhost";
|
|
536
|
+
}
|
|
537
|
+
function pidAlive(pid) {
|
|
538
|
+
if (!Number.isFinite(pid) || pid <= 0) return false;
|
|
539
|
+
try {
|
|
540
|
+
process.kill(pid, 0);
|
|
541
|
+
return true;
|
|
542
|
+
} catch (e) {
|
|
543
|
+
return e.code === "EPERM";
|
|
544
|
+
}
|
|
587
545
|
}
|
|
588
546
|
/**
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
547
|
+
* Pure steal decision for a per-inbox lock (extracted so it's unit-testable):
|
|
548
|
+
* steal the lock ONLY when its holder is provably gone (torn/missing owner on a
|
|
549
|
+
* lock that has EXISTED longer than the grace, or a dead pid) or its heartbeat is
|
|
550
|
+
* stale — NEVER merely because the contender has waited a while.
|
|
592
551
|
*
|
|
593
|
-
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
552
|
+
* Crucially, the torn-owner grace is measured from the LOCK INSTANCE's age
|
|
553
|
+
* (`lockAgeMs` = now − lockDir mtime), NOT from how long the contender has been
|
|
554
|
+
* waiting. Otherwise a contender that waited out one holder could instantly steal
|
|
555
|
+
* the NEXT holder's freshly-created lock during its mkdir→write-owner window
|
|
556
|
+
* (torn but brand-new) — letting two writers into the critical section.
|
|
597
557
|
*/
|
|
598
|
-
function
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
let
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
let j = i;
|
|
614
|
-
for (; j < n; j++) {
|
|
615
|
-
const mk = markerOf(lines[j]);
|
|
616
|
-
if (mk === null) break;
|
|
617
|
-
if (lines[j].includes(ANCHOR)) hasAnchor = true;
|
|
618
|
-
counts[mk]++;
|
|
619
|
-
}
|
|
620
|
-
const total = counts.done + counts.inprogress + counts.pending;
|
|
621
|
-
if (hasAnchor && total >= 2) best = {
|
|
622
|
-
done: counts.done,
|
|
623
|
-
total
|
|
624
|
-
};
|
|
625
|
-
i = j === i ? i + 1 : j;
|
|
626
|
-
}
|
|
627
|
-
return best;
|
|
558
|
+
function shouldStealLock(ownerRaw, now, opts) {
|
|
559
|
+
const graceMs = opts.graceMs ?? 1e3;
|
|
560
|
+
let ownerPid = 0;
|
|
561
|
+
let ownerTs = 0;
|
|
562
|
+
let parsed = false;
|
|
563
|
+
try {
|
|
564
|
+
const o = JSON.parse(ownerRaw);
|
|
565
|
+
ownerPid = o?.pid ?? 0;
|
|
566
|
+
ownerTs = o?.ts ?? 0;
|
|
567
|
+
parsed = true;
|
|
568
|
+
} catch {}
|
|
569
|
+
if (!parsed || ownerPid <= 0 || ownerTs <= 0) return opts.lockAgeMs > graceMs;
|
|
570
|
+
const holderDead = ownerPid !== opts.selfPid && !opts.isAlive(ownerPid);
|
|
571
|
+
const holderStale = now - ownerTs > opts.staleMs;
|
|
572
|
+
return holderDead || holderStale;
|
|
628
573
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
{
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
|
574
|
+
async function ensureDir(dir) {
|
|
575
|
+
await mkdir(dir, { recursive: true }).catch(() => {});
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Atomically overwrite a single file (temp + rename) so a concurrent reader never
|
|
579
|
+
* observes a torn/partial write — the sweep invariant for every single-file
|
|
580
|
+
* mutation (cursor, watcher heartbeat, lock owner). Returns whether it landed.
|
|
581
|
+
*/
|
|
582
|
+
async function atomicWrite(file, data) {
|
|
583
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
584
|
+
try {
|
|
585
|
+
await writeFile(tmp, data);
|
|
586
|
+
await rename(tmp, file);
|
|
587
|
+
return true;
|
|
588
|
+
} catch {
|
|
589
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
590
|
+
return false;
|
|
650
591
|
}
|
|
651
|
-
|
|
592
|
+
}
|
|
652
593
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
594
|
+
* Acquire a per-inbox lock; returns a release fn. Ownership is proven ONLY by a
|
|
595
|
+
* successful `mkdir(recursive:false)` (atomic exclusive create), and stealing is
|
|
596
|
+
* driven by HOLDER LIVENESS, not elapsed wait time: the holder writes an
|
|
597
|
+
* `owner` file ({pid, ts}) on acquire AND heartbeats its ts for the whole time
|
|
598
|
+
* it holds the lock, so a contender steals ONLY when that holder is dead or its
|
|
599
|
+
* heartbeat is stale. A live holder whose critical section legitimately runs long
|
|
600
|
+
* (a big GC rewrite, a slow disk) is never robbed — its heartbeat stays fresh;
|
|
601
|
+
* and a wedged holder stops heartbeating, so staleMs still reclaims it. There is
|
|
602
|
+
* deliberately NO wall-clock backstop: elapsed wait time alone never steals, so a
|
|
603
|
+
* live holder is inviolable.
|
|
655
604
|
*/
|
|
656
|
-
function
|
|
657
|
-
const
|
|
658
|
-
|
|
605
|
+
async function acquireLock(lockDir, staleMs = 3e4) {
|
|
606
|
+
const ownerFile = path.join(lockDir, "owner");
|
|
607
|
+
const token = randomUUID();
|
|
608
|
+
const readOwnerToken = async () => {
|
|
609
|
+
try {
|
|
610
|
+
return JSON.parse(await readFile(ownerFile, "utf8")).token ?? null;
|
|
611
|
+
} catch {
|
|
612
|
+
return null;
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const stampOwner = async () => {
|
|
616
|
+
const tmp = `${ownerFile}.${token}.tmp`;
|
|
617
|
+
try {
|
|
618
|
+
await writeFile(tmp, JSON.stringify({
|
|
619
|
+
pid: process.pid,
|
|
620
|
+
ts: Date.now(),
|
|
621
|
+
token
|
|
622
|
+
}));
|
|
623
|
+
await rename(tmp, ownerFile);
|
|
624
|
+
return true;
|
|
625
|
+
} catch {
|
|
626
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
const lockAgeMs = async () => {
|
|
631
|
+
const m = await stat(lockDir).then((s) => s.mtimeMs).catch(() => Date.now());
|
|
632
|
+
return Date.now() - m;
|
|
633
|
+
};
|
|
634
|
+
for (;;) try {
|
|
635
|
+
await mkdir(lockDir, { recursive: false });
|
|
636
|
+
if (!await stampOwner()) {
|
|
637
|
+
await rm(lockDir, {
|
|
638
|
+
recursive: true,
|
|
639
|
+
force: true
|
|
640
|
+
}).catch(() => {});
|
|
641
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const beat = setInterval(() => {
|
|
645
|
+
(async () => {
|
|
646
|
+
if (await readOwnerToken() !== token) {
|
|
647
|
+
clearInterval(beat);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
await stampOwner();
|
|
651
|
+
})();
|
|
652
|
+
}, Math.max(500, Math.floor(staleMs / 3)));
|
|
653
|
+
if (typeof beat.unref === "function") beat.unref();
|
|
654
|
+
return async () => {
|
|
655
|
+
clearInterval(beat);
|
|
656
|
+
if (await readOwnerToken() === token) await rm(lockDir, {
|
|
657
|
+
recursive: true,
|
|
658
|
+
force: true
|
|
659
|
+
}).catch(() => {});
|
|
660
|
+
};
|
|
661
|
+
} catch (e) {
|
|
662
|
+
if (e.code !== "EEXIST") throw e;
|
|
663
|
+
if (shouldStealLock(await readFile(ownerFile, "utf8").catch(() => ""), Date.now(), {
|
|
664
|
+
staleMs,
|
|
665
|
+
lockAgeMs: await lockAgeMs(),
|
|
666
|
+
selfPid: process.pid,
|
|
667
|
+
isAlive: pidAlive
|
|
668
|
+
})) {
|
|
669
|
+
await rm(lockDir, {
|
|
670
|
+
recursive: true,
|
|
671
|
+
force: true
|
|
672
|
+
}).catch(() => {});
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
676
|
+
}
|
|
659
677
|
}
|
|
660
678
|
/**
|
|
661
|
-
*
|
|
662
|
-
*
|
|
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.
|
|
679
|
+
* Append one event to a parent's inbox under lock, allocating the next seq. The
|
|
680
|
+
* caller passes the event WITHOUT seq (we stamp it). Returns the stamped seq.
|
|
666
681
|
*/
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
682
|
+
async function appendEvent(parentPid, ev) {
|
|
683
|
+
const host = ev.host;
|
|
684
|
+
const dir = inboxDir(host);
|
|
685
|
+
await ensureDir(dir);
|
|
686
|
+
const release = await acquireLock(path.join(dir, `${parentPid}.lock`));
|
|
687
|
+
try {
|
|
688
|
+
let last = 0;
|
|
689
|
+
const counterRaw = await readFile(seqPath(host, parentPid), "utf8").catch(() => "");
|
|
690
|
+
const parsed = parseInt(counterRaw.trim(), 10);
|
|
691
|
+
if (Number.isFinite(parsed) && parsed > 0) last = parsed;
|
|
692
|
+
else last = maxSeq(parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => "")));
|
|
693
|
+
const seq = nextSeq(last);
|
|
694
|
+
const full = {
|
|
695
|
+
...ev,
|
|
696
|
+
seq
|
|
697
|
+
};
|
|
698
|
+
await writeFile(seqPath(host, parentPid), String(seq));
|
|
699
|
+
await appendFile(inboxPath(host, parentPid), serializeEvent(full) + "\n");
|
|
700
|
+
return seq;
|
|
701
|
+
} finally {
|
|
702
|
+
await release();
|
|
703
|
+
}
|
|
675
704
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
function reTest(re, s) {
|
|
680
|
-
return (re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re).test(s);
|
|
705
|
+
/** Read + parse a parent's inbox (empty array when none). */
|
|
706
|
+
async function readInbox(host, parentPid) {
|
|
707
|
+
return parseInboxText(await readFile(inboxPath(host, parentPid), "utf8").catch(() => ""));
|
|
681
708
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
709
|
+
/** All parent pids that currently have an inbox on this host. */
|
|
710
|
+
async function listInboxParents(host) {
|
|
711
|
+
const names = await readdir(inboxDir(host)).catch(() => []);
|
|
712
|
+
const pids = [];
|
|
713
|
+
for (const n of names) {
|
|
714
|
+
const m = /^(\d+)\.ndjson$/.exec(n);
|
|
715
|
+
if (m) pids.push(parseInt(m[1], 10));
|
|
716
|
+
}
|
|
717
|
+
return pids;
|
|
718
|
+
}
|
|
719
|
+
async function getCursor(host, parentPid, consumer = "parent") {
|
|
720
|
+
return parseCursor(await readFile(cursorPath(host, parentPid, consumer), "utf8").catch(() => null)).seq;
|
|
685
721
|
}
|
|
686
722
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
723
|
+
* Minimum cursor across ALL consumers of a parent inbox — the watermark below
|
|
724
|
+
* which rotation may evict events (nothing above it has been acked by every
|
|
725
|
+
* reader). Returns 0 when there are no consumers — which PROTECTS EVERYTHING:
|
|
726
|
+
* with a 0 watermark, `rotateKeep` treats every event as unacked and evicts
|
|
727
|
+
* nothing (an inbox nobody reads is never trimmed).
|
|
690
728
|
*/
|
|
691
|
-
function
|
|
692
|
-
const
|
|
693
|
-
if (
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
729
|
+
async function minConsumerCursor(host, parentPid) {
|
|
730
|
+
const cursors = (await readdir(cursorDir(host, parentPid)).catch(() => [])).filter((n) => n.endsWith(".json")).map((n) => n.slice(0, -5));
|
|
731
|
+
if (cursors.length === 0) return 0;
|
|
732
|
+
let min = Infinity;
|
|
733
|
+
for (const c of cursors) min = Math.min(min, await getCursor(host, parentPid, c));
|
|
734
|
+
return Number.isFinite(min) ? min : 0;
|
|
735
|
+
}
|
|
736
|
+
async function setCursor(host, parentPid, seq, consumer = "parent") {
|
|
737
|
+
const p = cursorPath(host, parentPid, consumer);
|
|
738
|
+
await ensureDir(path.dirname(p));
|
|
739
|
+
await atomicWrite(p, serializeCursor(seq));
|
|
702
740
|
}
|
|
703
|
-
const OPTION_LINE = /^[\s❯›>▶◉○●·*-]*?(\d+)\.\s/;
|
|
704
741
|
/**
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
* screen isn't a menu (delegates that judgement to {@link classifyNeedsInput},
|
|
709
|
-
* so `working` still wins) or no numbered cursor line is found. Pure — the
|
|
710
|
-
* `ay select` action reuses the exact detection `ay ls` renders with.
|
|
742
|
+
* Retention: delete inboxes (+ their seq counter + cursors) for parents that are
|
|
743
|
+
* both dead and unreferenced by any live child. `capBytes` also rotates a live
|
|
744
|
+
* inbox that has grown past the cap, keeping the newest events.
|
|
711
745
|
*/
|
|
712
|
-
function
|
|
713
|
-
const
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
const
|
|
729
|
-
|
|
746
|
+
async function gcInboxes(host, livePids, liveChildParentPids, capBytes = 10 * 1024 * 1024) {
|
|
747
|
+
const parents = await listInboxParents(host);
|
|
748
|
+
for (const p of parents) {
|
|
749
|
+
const release = await acquireLock(path.join(inboxDir(host), `${p}.lock`));
|
|
750
|
+
try {
|
|
751
|
+
if (!livePids.has(p) && !liveChildParentPids.has(p)) {
|
|
752
|
+
await rm(inboxPath(host, p), { force: true }).catch(() => {});
|
|
753
|
+
await rm(seqPath(host, p), { force: true }).catch(() => {});
|
|
754
|
+
await rm(cursorDir(host, p), {
|
|
755
|
+
recursive: true,
|
|
756
|
+
force: true
|
|
757
|
+
}).catch(() => {});
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
const size = await stat(inboxPath(host, p)).then((s) => s.size).catch(() => 0);
|
|
761
|
+
if (size <= capBytes) continue;
|
|
762
|
+
const events = await readInbox(host, p);
|
|
763
|
+
const kept = rotateKeep(events, capBytes, await minConsumerCursor(host, p));
|
|
764
|
+
if (kept.length < events.length) await writeFile(inboxPath(host, p), kept.map(serializeEvent).join("\n") + "\n");
|
|
765
|
+
else logger.warn(`[notify] inbox for parent ${p} is ${size} bytes (> soft cap ${capBytes}) but all events are unacked — cursor not advancing?`);
|
|
766
|
+
} finally {
|
|
767
|
+
await release();
|
|
730
768
|
}
|
|
731
769
|
}
|
|
732
|
-
if (!options.includes(cursor)) options.push(cursor);
|
|
733
|
-
options.sort((a, b) => a - b);
|
|
734
|
-
return {
|
|
735
|
-
cursor,
|
|
736
|
-
options,
|
|
737
|
-
question: ni.question
|
|
738
|
-
};
|
|
739
770
|
}
|
|
740
|
-
/**
|
|
741
|
-
*
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
771
|
+
/** Register / refresh this parent's watch heartbeat (atomically, so a concurrent
|
|
772
|
+
* liveWatchers never reads a torn file and transiently drops the watcher). */
|
|
773
|
+
async function heartbeatWatcher(parentPid, startedAt) {
|
|
774
|
+
await ensureDir(watchersDir());
|
|
775
|
+
await atomicWrite(watcherPath(parentPid), JSON.stringify({
|
|
776
|
+
pid: parentPid,
|
|
777
|
+
started_at: startedAt,
|
|
778
|
+
ts: Date.now()
|
|
779
|
+
}));
|
|
780
|
+
}
|
|
781
|
+
/** Remove this parent's watch heartbeat (on watch exit). */
|
|
782
|
+
async function clearWatcher(parentPid) {
|
|
783
|
+
await rm(watcherPath(parentPid), { force: true }).catch(() => {});
|
|
752
784
|
}
|
|
753
|
-
|
|
754
|
-
//#endregion
|
|
755
|
-
//#region ts/lsWatch.ts
|
|
756
785
|
/**
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
* the
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
766
|
-
* never silently dropped.
|
|
786
|
+
* Live watchers as a map of parent pid → the parent's own `started_at` (its
|
|
787
|
+
* authoritative self-reported start time, so the daemon never has to guess it
|
|
788
|
+
* from the registry and stamp a 0). A watcher counts as live ONLY if its
|
|
789
|
+
* heartbeat is fresh AND its process is actually alive — so a crashed `watch`
|
|
790
|
+
* whose heartbeat lingers for the TTL doesn't keep the daemon writing to a dead
|
|
791
|
+
* parent's inbox (which would violate "nothing happens unless you watch"). A
|
|
792
|
+
* heartbeat with a missing/non-positive `started_at` is treated as NOT live: the
|
|
793
|
+
* daemon's cross-session scope guard keys off a positive parent start time, so a
|
|
794
|
+
* 0 would defeat it — fail closed.
|
|
767
795
|
*/
|
|
768
|
-
function
|
|
769
|
-
const
|
|
770
|
-
const
|
|
771
|
-
const
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
prev_state: null
|
|
779
|
-
});
|
|
780
|
-
else if (p.state !== a.state || p.question !== a.question) events.push({
|
|
781
|
-
...toEvent(a, ts),
|
|
782
|
-
prev_state: p.state
|
|
783
|
-
});
|
|
796
|
+
async function liveWatchers(now = Date.now()) {
|
|
797
|
+
const names = await readdir(watchersDir()).catch(() => []);
|
|
798
|
+
const live = /* @__PURE__ */ new Map();
|
|
799
|
+
for (const n of names) {
|
|
800
|
+
if (!n.endsWith(".json")) continue;
|
|
801
|
+
const raw = await readFile(path.join(watchersDir(), n), "utf8").catch(() => "");
|
|
802
|
+
try {
|
|
803
|
+
const o = JSON.parse(raw);
|
|
804
|
+
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);
|
|
805
|
+
} catch {}
|
|
784
806
|
}
|
|
785
|
-
|
|
786
|
-
ts,
|
|
787
|
-
pid,
|
|
788
|
-
cli: p.cli,
|
|
789
|
-
cwd: p.cwd,
|
|
790
|
-
state: "stopped",
|
|
791
|
-
question: null,
|
|
792
|
-
prev_state: p.state
|
|
793
|
-
});
|
|
794
|
-
return {
|
|
795
|
-
events,
|
|
796
|
-
next
|
|
797
|
-
};
|
|
798
|
-
}
|
|
799
|
-
function toEvent(a, ts) {
|
|
800
|
-
return {
|
|
801
|
-
ts,
|
|
802
|
-
pid: a.pid,
|
|
803
|
-
cli: a.cli,
|
|
804
|
-
cwd: a.cwd,
|
|
805
|
-
state: a.state,
|
|
806
|
-
question: a.question
|
|
807
|
-
};
|
|
807
|
+
return live;
|
|
808
808
|
}
|
|
809
809
|
|
|
810
810
|
//#endregion
|
|
@@ -968,6 +968,25 @@ async function recordRead(by, target) {
|
|
|
968
968
|
async function lastReadAt(by, target) {
|
|
969
969
|
return (await readReads()).get(`${by}${READS_KEY_SEP}${target}`) ?? null;
|
|
970
970
|
}
|
|
971
|
+
async function recentReadEdges(windowMs = READ_WINDOW_MS) {
|
|
972
|
+
const now = Date.now();
|
|
973
|
+
const map = await readReads();
|
|
974
|
+
const out = [];
|
|
975
|
+
for (const [key, at] of map) {
|
|
976
|
+
if (now - at > windowMs) continue;
|
|
977
|
+
const i = key.indexOf(READS_KEY_SEP);
|
|
978
|
+
const by = key.slice(0, i);
|
|
979
|
+
if (!by.startsWith("agent:")) continue;
|
|
980
|
+
const byPid = Number(by.slice(6));
|
|
981
|
+
const target = Number(key.slice(i + 1));
|
|
982
|
+
if (byPid && target && byPid !== target) out.push({
|
|
983
|
+
by: byPid,
|
|
984
|
+
target,
|
|
985
|
+
at
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
971
990
|
async function resolveSender() {
|
|
972
991
|
const envPid = process.env.AGENT_YES_PID ? Number(process.env.AGENT_YES_PID) : null;
|
|
973
992
|
if (!envPid || Number.isNaN(envPid)) return null;
|
|
@@ -1126,19 +1145,19 @@ async function runSubcommand(argv) {
|
|
|
1126
1145
|
case "restart": return await cmdRestart(rest);
|
|
1127
1146
|
case "note": return await cmdNote(rest);
|
|
1128
1147
|
case "serve": {
|
|
1129
|
-
const { cmdServe } = await import("./serve-
|
|
1148
|
+
const { cmdServe } = await import("./serve-B5PQdiOb.js");
|
|
1130
1149
|
return cmdServe(rest);
|
|
1131
1150
|
}
|
|
1132
1151
|
case "setup": {
|
|
1133
|
-
const { cmdSetup } = await import("./setup-
|
|
1152
|
+
const { cmdSetup } = await import("./setup-BCqFNJoB.js");
|
|
1134
1153
|
return cmdSetup(rest);
|
|
1135
1154
|
}
|
|
1136
1155
|
case "schedule": {
|
|
1137
|
-
const { cmdSchedule } = await import("./schedule-
|
|
1156
|
+
const { cmdSchedule } = await import("./schedule-BQtFOk0z.js");
|
|
1138
1157
|
return cmdSchedule(rest);
|
|
1139
1158
|
}
|
|
1140
1159
|
case "remote": {
|
|
1141
|
-
const { cmdRemote } = await import("./remotes-
|
|
1160
|
+
const { cmdRemote } = await import("./remotes-BIHNxn1a.js");
|
|
1142
1161
|
return cmdRemote(rest);
|
|
1143
1162
|
}
|
|
1144
1163
|
case "reap":
|
|
@@ -1531,7 +1550,7 @@ async function fetchRemoteRecordsRaw(url, token, opts) {
|
|
|
1531
1550
|
let base = url;
|
|
1532
1551
|
let bearer = token;
|
|
1533
1552
|
if (isWebrtcSpec(url)) {
|
|
1534
|
-
const { startWebrtcBridge } = await import("./webrtcRemote-
|
|
1553
|
+
const { startWebrtcBridge } = await import("./webrtcRemote-BLEbZnbY.js");
|
|
1535
1554
|
bridge = await startWebrtcBridge(url);
|
|
1536
1555
|
base = bridge.baseUrl;
|
|
1537
1556
|
bearer = bridge.token;
|
|
@@ -3040,7 +3059,10 @@ function stopTipForCli(cli, pid) {
|
|
|
3040
3059
|
const GRACEFUL_EXIT_COMMANDS = {
|
|
3041
3060
|
claude: "/exit",
|
|
3042
3061
|
codex: "/exit",
|
|
3043
|
-
gemini: "/quit"
|
|
3062
|
+
gemini: "/quit",
|
|
3063
|
+
bash: "exit",
|
|
3064
|
+
cmd: "exit",
|
|
3065
|
+
powershell: "exit"
|
|
3044
3066
|
};
|
|
3045
3067
|
function controlCodeFromName(name) {
|
|
3046
3068
|
switch (name) {
|
|
@@ -3969,7 +3991,7 @@ async function cmdNotify(rest) {
|
|
|
3969
3991
|
}
|
|
3970
3992
|
const ensure = async () => {
|
|
3971
3993
|
if (!argv["ensure-daemon"]) return;
|
|
3972
|
-
const { ensureDaemon } = await import("./notifyDaemon-
|
|
3994
|
+
const { ensureDaemon } = await import("./notifyDaemon-C08fJ2Ai.js");
|
|
3973
3995
|
await ensureDaemon().catch(() => null);
|
|
3974
3996
|
};
|
|
3975
3997
|
await heartbeatWatcher(parent, selfStartedAt);
|
|
@@ -4043,7 +4065,7 @@ async function cmdNotifyCursor(args) {
|
|
|
4043
4065
|
}
|
|
4044
4066
|
async function cmdNotifyd(rest) {
|
|
4045
4067
|
const sub = rest[0] ?? "status";
|
|
4046
|
-
const daemon = await import("./notifyDaemon-
|
|
4068
|
+
const daemon = await import("./notifyDaemon-C08fJ2Ai.js");
|
|
4047
4069
|
switch (sub) {
|
|
4048
4070
|
case "run": return daemon.runDaemon();
|
|
4049
4071
|
case "once": return daemon.runDaemon({ once: true });
|
|
@@ -4069,5 +4091,5 @@ async function cmdNotifyd(rest) {
|
|
|
4069
4091
|
}
|
|
4070
4092
|
|
|
4071
4093
|
//#endregion
|
|
4072
|
-
export {
|
|
4073
|
-
//# sourceMappingURL=subcommands-
|
|
4094
|
+
export { TYPING_BADGE as $, renderRawLogLines as A, waitForLogQuiet as B, matchKeyword as C, recentReadEdges as D, readPtysize as E, runSubcommand as F, hostId as G, writeToIpc as H, snapshotStatus as I, readInbox as J, listInboxParents as K, stdinActivityPath as L, resolveReadWindow as M, resolveResumeArgs as N, renderLogTailLines as O, restartHintLines as P, notifyDir as Q, stopTipForCli as R, listRecords as S, readNotes as T, appendEvent as U, writeKeysPaced 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, resolveOne as j, renderRawLog 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, submitAndConfirm as z };
|
|
4095
|
+
//# sourceMappingURL=subcommands-BbSJ_nTU.js.map
|