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
package/ts/subcommands.ts
CHANGED
|
@@ -18,7 +18,8 @@ import path from "path";
|
|
|
18
18
|
import { type GlobalPidRecord, readGlobalPids, updateGlobalPidStatus } from "./globalPidIndex.ts";
|
|
19
19
|
import { buildAgentForest, flattenForest } from "./agentTree.ts";
|
|
20
20
|
import { parseTaskCounts, type TaskCounts } from "./todoParse.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { agentYesHome } from "./agentYesHome.ts";
|
|
22
|
+
import { badgeDef, matchBadges, TYPING_BADGE } from "./badges.ts";
|
|
22
23
|
import {
|
|
23
24
|
classifyNeedsInput,
|
|
24
25
|
isWorkingScreen,
|
|
@@ -27,6 +28,15 @@ import {
|
|
|
27
28
|
type NeedsInput,
|
|
28
29
|
} from "./needsInput.ts";
|
|
29
30
|
import { diffLsStates, type LiveState, type LsAgentState } from "./lsWatch.ts";
|
|
31
|
+
import { filterSinceSeq, filterSinceTs, filterUnread, maxSeq, type NotifyEvent } from "./notifyInbox.ts";
|
|
32
|
+
import {
|
|
33
|
+
clearWatcher,
|
|
34
|
+
getCursor,
|
|
35
|
+
heartbeatWatcher,
|
|
36
|
+
hostId,
|
|
37
|
+
readInbox,
|
|
38
|
+
setCursor,
|
|
39
|
+
} from "./notifyStore.ts";
|
|
30
40
|
import {
|
|
31
41
|
buildStoredResult,
|
|
32
42
|
normalizeEnvelope,
|
|
@@ -247,12 +257,16 @@ function mergeRecords(...buckets: GlobalPidRecord[][]): GlobalPidRecord[] {
|
|
|
247
257
|
|
|
248
258
|
// Subcommands EVERY *-yes binary accepts — inspection/messaging over the shared
|
|
249
259
|
// agent registry (`cy ls`, `cy send`, `cy tail`, …).
|
|
260
|
+
// MIRRORED in rs/src/cli.rs `SUBCOMMANDS` — the Rust runner delegates these to
|
|
261
|
+
// this JS layer; keep the two lists in sync.
|
|
250
262
|
const SUBCOMMANDS = new Set([
|
|
251
263
|
"ls",
|
|
252
264
|
"list",
|
|
253
265
|
"ps",
|
|
254
266
|
"status",
|
|
255
267
|
"result",
|
|
268
|
+
"notify",
|
|
269
|
+
"notifyd",
|
|
256
270
|
"read",
|
|
257
271
|
"cat",
|
|
258
272
|
"tail",
|
|
@@ -291,6 +305,25 @@ const STUCK_THRESHOLD_MS = (() => {
|
|
|
291
305
|
return Number.isFinite(n) && n > 0 ? n : 5 * 60 * 1000;
|
|
292
306
|
})();
|
|
293
307
|
|
|
308
|
+
// `ay send` submit-confirm tuning. A long/multi-line body pasted via bracketed
|
|
309
|
+
// paste can take longer than any fixed delay to finish rendering — sending the
|
|
310
|
+
// trailing Enter before that settles gets swallowed by the CLI's paste handling
|
|
311
|
+
// (it lands mid-paste instead of submitting). So instead of a blind fixed sleep,
|
|
312
|
+
// we poll the log for actual quiet, then confirm the Enter landed by watching for
|
|
313
|
+
// either a `working` busy marker or a meaningful size bump, retrying if not.
|
|
314
|
+
const SEND_SETTLE_QUIET_MS = 150; // no log growth for this long → paste finished rendering
|
|
315
|
+
const SEND_SETTLE_MAX_MS = 1500; // cap: don't wait forever on a screen that's busy for other reasons
|
|
316
|
+
const SEND_CONFIRM_QUIET_MS = 400; // after Enter, no growth for this long → response has settled
|
|
317
|
+
const SEND_CONFIRM_MAX_MS = 1200; // cap per confirm attempt
|
|
318
|
+
const SEND_CONFIRM_MIN_GROWTH_BYTES = 8; // filters out pure cursor-blink/frame noise
|
|
319
|
+
const SEND_SUBMIT_MAX_RETRIES = 2; // total attempts = 1 + this
|
|
320
|
+
// `ay send` typing-backoff: if the user is actively typing at the target's
|
|
321
|
+
// terminal, injecting our body mid-line would fuse into their text and submit a
|
|
322
|
+
// mangled line. Poll until they pause (activity older than TYPING_WINDOW_MS) or
|
|
323
|
+
// we give up, then send anyway with a warning rather than dropping the message.
|
|
324
|
+
const SEND_TYPING_POLL_MS = 200;
|
|
325
|
+
const SEND_TYPING_MAX_WAIT_MS = 10_000;
|
|
326
|
+
|
|
294
327
|
/**
|
|
295
328
|
* Whether `name` is a subcommand. `managerCommands` (default true, for the
|
|
296
329
|
* generic `ay`/`agent-yes` entry) additionally admits manager-only commands
|
|
@@ -326,6 +359,10 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
|
|
|
326
359
|
return await cmdStatus(rest);
|
|
327
360
|
case "result":
|
|
328
361
|
return await cmdResult(rest);
|
|
362
|
+
case "notify":
|
|
363
|
+
return await cmdNotify(rest);
|
|
364
|
+
case "notifyd":
|
|
365
|
+
return await cmdNotifyd(rest);
|
|
329
366
|
case "read":
|
|
330
367
|
case "cat":
|
|
331
368
|
return await cmdRead(rest, { mode: "cat" });
|
|
@@ -1076,14 +1113,57 @@ async function runAllRemotesLs(opts: {
|
|
|
1076
1113
|
),
|
|
1077
1114
|
]);
|
|
1078
1115
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1116
|
+
// Group by host in a stable order (local first, then each remote alias in
|
|
1117
|
+
// config order), so the aggregated table reads top-down per machine.
|
|
1118
|
+
const byHost: { host: string; records: any[] }[] = [];
|
|
1119
|
+
// Shared per-invocation caches so local agents in the same repo spawn
|
|
1120
|
+
// `git status` once (see gitStatusOnce).
|
|
1121
|
+
const gitRootCache = new Map<string, string>();
|
|
1122
|
+
const gitInfoCache = new Map<string, GitInfo | null>();
|
|
1081
1123
|
if (localResult.status === "fulfilled") {
|
|
1082
|
-
|
|
1124
|
+
// Local records come from listRecords() RAW — unlike the remote /api/ls
|
|
1125
|
+
// payload they carry no derived live-state, last_active_at, task counts,
|
|
1126
|
+
// status badges, or git tag, so left alone they'd render a flat "active"
|
|
1127
|
+
// with no age/badge/git. Enrich them to the same shape the API returns, so
|
|
1128
|
+
// local agents get the same idle/needs_input/stuck status, staleness age,
|
|
1129
|
+
// task badges, status-flag chips, and git dirty/sync tag.
|
|
1130
|
+
const enriched = await Promise.all(
|
|
1131
|
+
localResult.value.records.map(async (r) => {
|
|
1132
|
+
const { state, question } = await deriveLiveState(r);
|
|
1133
|
+
const alive = state !== "stopped";
|
|
1134
|
+
const [tasks, badges, git, typing] = alive
|
|
1135
|
+
? await Promise.all([
|
|
1136
|
+
r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
|
|
1137
|
+
r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
|
|
1138
|
+
gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
|
|
1139
|
+
isUserTyping(r.pid),
|
|
1140
|
+
])
|
|
1141
|
+
: [null, [], null, false];
|
|
1142
|
+
return {
|
|
1143
|
+
...r,
|
|
1144
|
+
status: state,
|
|
1145
|
+
question,
|
|
1146
|
+
last_active_at: await deriveLastActiveAt(r),
|
|
1147
|
+
tasks,
|
|
1148
|
+
badges: typing ? [...(badges as string[]), TYPING_BADGE.id] : badges,
|
|
1149
|
+
git,
|
|
1150
|
+
};
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
1153
|
+
byHost.push({ host: "local", records: enriched });
|
|
1083
1154
|
}
|
|
1084
1155
|
for (const res of remoteResults) {
|
|
1085
|
-
if (res.status === "fulfilled") {
|
|
1086
|
-
|
|
1156
|
+
if (res.status === "fulfilled") byHost.push({ host: res.value.host, records: res.value.records });
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Flatten each host's records into its agent>subagent forest (parent_pid links),
|
|
1160
|
+
// carrying the box-drawing tree prefix — the same nesting the console's left
|
|
1161
|
+
// panel shows. Degrades to a flat newest-first list when there are no links.
|
|
1162
|
+
type HostedRow = { host: string; rec: any; prefix: string };
|
|
1163
|
+
const rows: HostedRow[] = [];
|
|
1164
|
+
for (const { host, records } of byHost) {
|
|
1165
|
+
for (const { record, prefix } of flattenForest(buildAgentForest(records))) {
|
|
1166
|
+
rows.push({ host, rec: record, prefix });
|
|
1087
1167
|
}
|
|
1088
1168
|
}
|
|
1089
1169
|
|
|
@@ -1093,12 +1173,19 @@ async function runAllRemotesLs(opts: {
|
|
|
1093
1173
|
}
|
|
1094
1174
|
|
|
1095
1175
|
const termWidth = (process.stdout as any).columns ?? 120;
|
|
1176
|
+
const now = Date.now();
|
|
1177
|
+
const ageOf = (rec: any) => humanizeAge(now - (rec.last_active_at ?? rec.started_at));
|
|
1178
|
+
const badgeOf = (rec: any) => (rec.tasks ? `${rec.tasks.done}/${rec.tasks.total} ` : "");
|
|
1096
1179
|
const hostW = Math.max(4, ...rows.map((r) => r.host.length));
|
|
1097
1180
|
const pidW = Math.max(3, ...rows.map((r) => String(r.rec.pid).length));
|
|
1098
1181
|
const cliW = Math.max(3, ...rows.map((r) => String(r.rec.cli).length));
|
|
1099
1182
|
const statusW = Math.max(6, ...rows.map((r) => String(r.rec.status).length));
|
|
1183
|
+
const ageW = Math.max(3, ...rows.map((r) => ageOf(r.rec).length));
|
|
1100
1184
|
const cwdW = Math.max(3, ...rows.map((r) => shortenPath(String(r.rec.cwd)).length));
|
|
1101
|
-
const promptBudget = Math.max(
|
|
1185
|
+
const promptBudget = Math.max(
|
|
1186
|
+
20,
|
|
1187
|
+
termWidth - hostW - pidW - cliW - statusW - ageW - cwdW - 6 * 2 - 1,
|
|
1188
|
+
);
|
|
1102
1189
|
|
|
1103
1190
|
process.stdout.write(
|
|
1104
1191
|
[
|
|
@@ -1106,18 +1193,33 @@ async function runAllRemotesLs(opts: {
|
|
|
1106
1193
|
"PID".padEnd(pidW),
|
|
1107
1194
|
"CLI".padEnd(cliW),
|
|
1108
1195
|
"STATUS".padEnd(statusW),
|
|
1196
|
+
"AGE".padEnd(ageW),
|
|
1109
1197
|
"CWD".padEnd(cwdW),
|
|
1110
1198
|
"PROMPT",
|
|
1111
1199
|
].join(" ") + "\n",
|
|
1112
1200
|
);
|
|
1113
|
-
for (const { host, rec } of rows) {
|
|
1114
|
-
|
|
1201
|
+
for (const { host, rec, prefix } of rows) {
|
|
1202
|
+
// The tree prefix + task badge + flag chips + git tag live inside the PROMPT
|
|
1203
|
+
// column, so they eat into this row's text budget — same as the single-host
|
|
1204
|
+
// table. Both local (enriched above) and remote (/api/ls) records carry
|
|
1205
|
+
// `tasks`, `badges`, and `git` in the same shape.
|
|
1206
|
+
const flagStr = badgeLabels(rec.badges);
|
|
1207
|
+
const branchStr = branchLabel(rec.git);
|
|
1208
|
+
const gitStr = gitLabel(rec.git);
|
|
1209
|
+
const deco =
|
|
1210
|
+
badgeOf(rec) +
|
|
1211
|
+
(flagStr ? flagStr + " " : "") +
|
|
1212
|
+
(branchStr ? branchStr + " " : "") +
|
|
1213
|
+
(gitStr ? gitStr + " " : "");
|
|
1214
|
+
const budget = Math.max(8, promptBudget - prefix.length - deco.length);
|
|
1215
|
+
const label = prefix + deco + (rec.prompt ? truncate(`→ ${rec.prompt}`, budget) : "");
|
|
1115
1216
|
process.stdout.write(
|
|
1116
1217
|
[
|
|
1117
1218
|
host.padEnd(hostW),
|
|
1118
1219
|
String(rec.pid).padEnd(pidW),
|
|
1119
1220
|
String(rec.cli).padEnd(cliW),
|
|
1120
1221
|
String(rec.status).padEnd(statusW),
|
|
1222
|
+
ageOf(rec).padEnd(ageW),
|
|
1121
1223
|
shortenPath(String(rec.cwd)).padEnd(cwdW),
|
|
1122
1224
|
label,
|
|
1123
1225
|
].join(" ") + "\n",
|
|
@@ -1156,7 +1258,7 @@ export async function deriveLiveStatus(r: GlobalPidRecord): Promise<"active" | "
|
|
|
1156
1258
|
* consumer. Builds on the cheap deriveLiveStatus, then adds the menu (needs_input)
|
|
1157
1259
|
* override, which DOES read the log tail.
|
|
1158
1260
|
*/
|
|
1159
|
-
async function deriveLiveState(
|
|
1261
|
+
export async function deriveLiveState(
|
|
1160
1262
|
r: GlobalPidRecord,
|
|
1161
1263
|
): Promise<{ state: LiveState; question: string | null }> {
|
|
1162
1264
|
const base = await deriveLiveStatus(r);
|
|
@@ -1176,6 +1278,155 @@ async function deriveLiveState(
|
|
|
1176
1278
|
return { state: base, question: null };
|
|
1177
1279
|
}
|
|
1178
1280
|
|
|
1281
|
+
/**
|
|
1282
|
+
* When the agent last wrote stdout — the log file's mtime, falling back to
|
|
1283
|
+
* started_at when there's no log yet (freshly spawned). Mirrors serve.ts's
|
|
1284
|
+
* `last_active_at`, so the `ay ls` AGE column measures STALENESS (time since the
|
|
1285
|
+
* agent last produced output) rather than lifetime — matching the console's
|
|
1286
|
+
* left-panel age. A long-lived but quiet agent then reads as stale, not "new".
|
|
1287
|
+
*/
|
|
1288
|
+
async function deriveLastActiveAt(r: GlobalPidRecord): Promise<number> {
|
|
1289
|
+
if (!r.log_file) return r.started_at;
|
|
1290
|
+
return stat(r.log_file)
|
|
1291
|
+
.then((s) => s.mtimeMs)
|
|
1292
|
+
.catch(() => r.started_at);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// Git dirty/sync counts for one repo, in the shape serve.ts's /api/ls returns
|
|
1296
|
+
// (so `ay ls` can format LOCAL agents' git the same way it formats remote ones,
|
|
1297
|
+
// whose `git` field already arrives in this shape).
|
|
1298
|
+
interface GitInfo {
|
|
1299
|
+
branch: string | null;
|
|
1300
|
+
dirty: boolean;
|
|
1301
|
+
changed: number; // real file changes (excludes submodule pin-bumps & internal dirt)
|
|
1302
|
+
pins: number; // submodule gitlinks pointing at new commit(s) — pin-bump/drift
|
|
1303
|
+
subDirty: number; // submodule has internal changes but its recorded pin is unchanged
|
|
1304
|
+
ahead: number;
|
|
1305
|
+
behind: number;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Format a GitInfo into the console's compact tag: "±3" changed files, "⑂2"
|
|
1310
|
+
* submodule pin-bumps, "⊙1" submodule internal dirt, "↑1" ahead, "↓2" behind.
|
|
1311
|
+
* Mirrors gitLabel() in lab/ui/console-logic.js so `ay ls` and the web panel's
|
|
1312
|
+
* left rail read identically. "" when clean / in sync / not a repo.
|
|
1313
|
+
*/
|
|
1314
|
+
function gitLabel(g: GitInfo | null | undefined): string {
|
|
1315
|
+
if (!g) return "";
|
|
1316
|
+
const parts: string[] = [];
|
|
1317
|
+
if (g.changed > 0) parts.push("±" + g.changed);
|
|
1318
|
+
if (g.pins > 0) parts.push("⑂" + g.pins);
|
|
1319
|
+
if (g.subDirty > 0) parts.push("⊙" + g.subDirty);
|
|
1320
|
+
if (g.ahead > 0) parts.push("↑" + g.ahead);
|
|
1321
|
+
if (g.behind > 0) parts.push("↓" + g.behind);
|
|
1322
|
+
return parts.join(" ");
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* The checked-out branch as "⎇<name>" (⎇ = the branch/alt-key glyph). Shows the
|
|
1327
|
+
* ACTUAL git branch, which can differ from the worktree folder in the cwd (a
|
|
1328
|
+
* feature branch checked out in .../tree/main, a detached HEAD, etc.). "" when
|
|
1329
|
+
* detached / not a repo. Kept separate from gitLabel so gitLabel stays a mirror
|
|
1330
|
+
* of the web console's tag.
|
|
1331
|
+
*/
|
|
1332
|
+
function branchLabel(g: GitInfo | null | undefined): string {
|
|
1333
|
+
return g?.branch ? "⎇" + g.branch : "";
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Short status-flag chips ("goal", "retry", "limit") for a list of badge ids —
|
|
1338
|
+
* the same flags the console shows, resolved to their labels via badges.ts.
|
|
1339
|
+
* "" when none. (Remote records carry `badges` from /api/ls; local ones are
|
|
1340
|
+
* matched here via extractBadges.)
|
|
1341
|
+
*/
|
|
1342
|
+
function badgeLabels(ids: string[] | null | undefined): string {
|
|
1343
|
+
if (!ids || ids.length === 0) return "";
|
|
1344
|
+
return ids.map((id) => badgeDef(id)?.label ?? id).join(" ");
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// porcelain=v2 --branch parser — mirrors parseGitStatus in serve.ts (that copy
|
|
1348
|
+
// lives inside the serve closure and is watcher-driven, so it can't be shared
|
|
1349
|
+
// without a refactor; keep the two in sync). Submodule pin-bumps/internal dirt
|
|
1350
|
+
// are split out of `changed` so a submodule-heavy repo doesn't read as dirty.
|
|
1351
|
+
function parseGitStatus(out: string): GitInfo {
|
|
1352
|
+
let branch: string | null = null;
|
|
1353
|
+
let ahead = 0;
|
|
1354
|
+
let behind = 0;
|
|
1355
|
+
let changed = 0;
|
|
1356
|
+
let pins = 0;
|
|
1357
|
+
let subDirty = 0;
|
|
1358
|
+
for (const line of out.split("\n")) {
|
|
1359
|
+
if (line.length === 0) continue;
|
|
1360
|
+
if (line[0] === "#") {
|
|
1361
|
+
const head = /^# branch\.head (.+)$/.exec(line);
|
|
1362
|
+
if (head) {
|
|
1363
|
+
branch = head[1] === "(detached)" ? null : head[1]!;
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
const ab = /^# branch\.ab \+(\d+) -(\d+)/.exec(line);
|
|
1367
|
+
if (ab) {
|
|
1368
|
+
ahead = Number(ab[1]);
|
|
1369
|
+
behind = Number(ab[2]);
|
|
1370
|
+
}
|
|
1371
|
+
continue;
|
|
1372
|
+
}
|
|
1373
|
+
const type = line[0];
|
|
1374
|
+
if (type === "?" || type === "u") {
|
|
1375
|
+
changed++;
|
|
1376
|
+
} else if (type === "1" || type === "2") {
|
|
1377
|
+
const sub = line.split(" ")[2] ?? "N...";
|
|
1378
|
+
if (sub[0] === "S") {
|
|
1379
|
+
if (sub[1] === "C") pins++;
|
|
1380
|
+
else subDirty++;
|
|
1381
|
+
} else {
|
|
1382
|
+
changed++;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
return { branch, dirty: changed > 0, changed, pins, subDirty, ahead, behind };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
async function runGitCli(args: string[], cwd: string): Promise<string | null> {
|
|
1390
|
+
try {
|
|
1391
|
+
const proc = Bun.spawn(["git", ...args], {
|
|
1392
|
+
cwd,
|
|
1393
|
+
stdout: "pipe",
|
|
1394
|
+
stderr: "ignore",
|
|
1395
|
+
signal: AbortSignal.timeout(2000),
|
|
1396
|
+
});
|
|
1397
|
+
const out = await new Response(proc.stdout).text();
|
|
1398
|
+
await proc.exited;
|
|
1399
|
+
return proc.exitCode === 0 ? out : null;
|
|
1400
|
+
} catch {
|
|
1401
|
+
return null; // git missing, not a repo, or timed out
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
/**
|
|
1406
|
+
* One-shot git status for the `ay ls` CLI. serve.ts keeps a per-repo watcher so
|
|
1407
|
+
* its request path never spawns git; a one-shot CLI has no watcher, so it spawns
|
|
1408
|
+
* `git status` directly — but deduped per repo root via the two caches, so N
|
|
1409
|
+
* agents sharing a repo (or its submodules/subdirs) cost ONE `git status`.
|
|
1410
|
+
*/
|
|
1411
|
+
async function gitStatusOnce(
|
|
1412
|
+
cwd: string | null | undefined,
|
|
1413
|
+
rootCache: Map<string, string>,
|
|
1414
|
+
infoCache: Map<string, GitInfo | null>,
|
|
1415
|
+
): Promise<GitInfo | null> {
|
|
1416
|
+
if (!cwd) return null;
|
|
1417
|
+
let root = rootCache.get(cwd);
|
|
1418
|
+
if (root === undefined) {
|
|
1419
|
+
root = ((await runGitCli(["rev-parse", "--show-toplevel"], cwd)) ?? "").trim();
|
|
1420
|
+
rootCache.set(cwd, root);
|
|
1421
|
+
}
|
|
1422
|
+
if (!root) return null; // not a git repo
|
|
1423
|
+
if (infoCache.has(root)) return infoCache.get(root)!;
|
|
1424
|
+
const out = await runGitCli(["status", "--porcelain=v2", "--branch"], root);
|
|
1425
|
+
const info = out != null ? parseGitStatus(out) : null;
|
|
1426
|
+
infoCache.set(root, info);
|
|
1427
|
+
return info;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1179
1430
|
async function cmdLs(rest: string[]): Promise<number> {
|
|
1180
1431
|
const y = yargs(rest)
|
|
1181
1432
|
.usage(
|
|
@@ -1335,12 +1586,21 @@ async function cmdLs(rest: string[]): Promise<number> {
|
|
|
1335
1586
|
// context and users on narrow ones don't get an awkwardly-wrapped table.
|
|
1336
1587
|
const termWidth = (process.stdout as any).columns ?? 120;
|
|
1337
1588
|
|
|
1589
|
+
// AGE is time since last stdout (staleness), not lifetime — same signal the
|
|
1590
|
+
// console's left panel shows. Precomputed here (one stat() per agent) so the
|
|
1591
|
+
// width pass and the row pass agree without stat()ing twice.
|
|
1592
|
+
const now = Date.now();
|
|
1593
|
+
const lastActive = new Map<number, number>(
|
|
1594
|
+
await Promise.all(records.map(async (r) => [r.pid, await deriveLastActiveAt(r)] as const)),
|
|
1595
|
+
);
|
|
1596
|
+
const ageOf = (r: GlobalPidRecord) => humanizeAge(now - (lastActive.get(r.pid) ?? r.started_at));
|
|
1597
|
+
|
|
1338
1598
|
const rawCwds = records.map((r) => shortenPath(r.cwd));
|
|
1339
1599
|
const widths = {
|
|
1340
1600
|
pid: Math.max(3, ...records.map((r) => String(r.pid).length)),
|
|
1341
1601
|
cli: Math.max(3, ...records.map((r) => r.cli.length)),
|
|
1342
1602
|
status: Math.max(6, ...records.map((r) => r.status.length)),
|
|
1343
|
-
age: Math.max(3, ...records.map((r) =>
|
|
1603
|
+
age: Math.max(3, ...records.map((r) => ageOf(r).length)),
|
|
1344
1604
|
cwd: Math.max(3, ...rawCwds.map((c) => c.length)),
|
|
1345
1605
|
};
|
|
1346
1606
|
const fixedWidth = widths.pid + widths.cli + widths.status + widths.age + widths.cwd + 5 * 2; // 5 separators of " "
|
|
@@ -1353,38 +1613,60 @@ async function cmdLs(rest: string[]): Promise<number> {
|
|
|
1353
1613
|
const forestRows = flattenForest(buildAgentForest(records));
|
|
1354
1614
|
|
|
1355
1615
|
const notes = await readNotes();
|
|
1616
|
+
// Shared per-invocation caches so agents in the same repo spawn `git status`
|
|
1617
|
+
// once (see gitStatusOnce). One `ay ls` call, not one per agent.
|
|
1618
|
+
const gitRootCache = new Map<string, string>();
|
|
1619
|
+
const gitInfoCache = new Map<string, GitInfo | null>();
|
|
1356
1620
|
const rows = await Promise.all(
|
|
1357
1621
|
forestRows.map(async ({ record: r, prefix }) => {
|
|
1358
1622
|
// Same live-state derivation as the --json path: stopped/idle/active, with
|
|
1359
1623
|
// needs_input when the agent is parked on an unanswered menu.
|
|
1360
1624
|
const displayStatus: string = (await deriveLiveState(r)).state;
|
|
1625
|
+
const alive = displayStatus !== "stopped";
|
|
1361
1626
|
const note = notes.get(r.pid);
|
|
1362
|
-
// Task progress ("2/5")
|
|
1363
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1627
|
+
// Task progress ("2/5"), status-flag chips ("goal"/"retry"/"limit"), and the
|
|
1628
|
+
// git dirty/sync tag ("±3 ⑂2 ↓1") — the same three decorations the console's
|
|
1629
|
+
// left rail shows. Skipped for stopped agents (screen no longer live).
|
|
1630
|
+
const [tasks, flags, git, typing] = alive
|
|
1631
|
+
? await Promise.all([
|
|
1632
|
+
r.log_file ? extractTaskCounts(r.log_file) : Promise.resolve(null),
|
|
1633
|
+
r.log_file ? extractBadges(r.log_file) : Promise.resolve([]),
|
|
1634
|
+
gitStatusOnce(r.cwd, gitRootCache, gitInfoCache),
|
|
1635
|
+
isUserTyping(r.pid),
|
|
1636
|
+
])
|
|
1637
|
+
: [null, [], null, false];
|
|
1638
|
+
const taskBadge = tasks ? `${tasks.done}/${tasks.total} ` : "";
|
|
1639
|
+
const flagStr = badgeLabels(typing ? [...(flags as string[]), TYPING_BADGE.id] : flags);
|
|
1640
|
+
const branchStr = branchLabel(git);
|
|
1641
|
+
const gitStr = gitLabel(git);
|
|
1642
|
+
// task badge, flag chips, then the git group (⎇branch + dirty/sync tag) —
|
|
1643
|
+
// compact, single-spaced.
|
|
1644
|
+
const deco =
|
|
1645
|
+
taskBadge +
|
|
1646
|
+
(flagStr ? flagStr + " " : "") +
|
|
1647
|
+
(branchStr ? branchStr + " " : "") +
|
|
1648
|
+
(gitStr ? gitStr + " " : "");
|
|
1649
|
+
// The tree branch prefix + these decorations sit inside the NOTE/PROMPT
|
|
1650
|
+
// column, so they eat into this row's text budget.
|
|
1651
|
+
const budget = Math.max(8, promptBudget - prefix.length - deco.length);
|
|
1370
1652
|
let label: string;
|
|
1371
1653
|
let hasNote = false;
|
|
1372
1654
|
if (note) {
|
|
1373
1655
|
label = truncate(note, budget);
|
|
1374
1656
|
hasNote = true;
|
|
1375
|
-
} else if (r.log_file &&
|
|
1657
|
+
} else if (r.log_file && alive) {
|
|
1376
1658
|
const activity = await extractActivity(r.log_file);
|
|
1377
1659
|
label = truncate(activity ?? (r.prompt ? `→ ${r.prompt}` : ""), budget);
|
|
1378
1660
|
} else {
|
|
1379
1661
|
label = truncate(r.prompt ? `→ ${r.prompt}` : "", budget);
|
|
1380
1662
|
}
|
|
1381
|
-
// Note marker +
|
|
1382
|
-
label = prefix + (hasNote ? "* " : "") +
|
|
1663
|
+
// Note marker + decorations sit after the branch prefix so the tree aligns.
|
|
1664
|
+
label = prefix + (hasNote ? "* " : "") + deco + label;
|
|
1383
1665
|
return {
|
|
1384
1666
|
pid: String(r.pid),
|
|
1385
1667
|
cli: r.cli,
|
|
1386
1668
|
status: displayStatus,
|
|
1387
|
-
age:
|
|
1669
|
+
age: ageOf(r),
|
|
1388
1670
|
cwd: shortenPath(r.cwd),
|
|
1389
1671
|
label,
|
|
1390
1672
|
hasNote,
|
|
@@ -2092,7 +2374,7 @@ function cliDefaults(): Promise<Record<string, AgentCliConfig>> {
|
|
|
2092
2374
|
* null on any read/render error or an empty log. Shared by the needs_input and
|
|
2093
2375
|
* stuck classifiers so they don't each re-implement the tail read.
|
|
2094
2376
|
*/
|
|
2095
|
-
async function renderLogTailLines(logPath: string, n = 40): Promise<string[] | null> {
|
|
2377
|
+
export async function renderLogTailLines(logPath: string, n = 40): Promise<string[] | null> {
|
|
2096
2378
|
const TAIL_BYTES = 32 * 1024;
|
|
2097
2379
|
let buf: Uint8Array;
|
|
2098
2380
|
try {
|
|
@@ -2140,6 +2422,36 @@ export async function extractBadges(logPath: string): Promise<string[]> {
|
|
|
2140
2422
|
return matchBadges(lines);
|
|
2141
2423
|
}
|
|
2142
2424
|
|
|
2425
|
+
// Window within which a recorded human keystroke still counts as "the user is
|
|
2426
|
+
// typing" — lights the chip and makes `ay send` back off. Comfortably longer
|
|
2427
|
+
// than the Rust writer's throttle (STDIN_ACTIVITY_THROTTLE_MS) so continuous
|
|
2428
|
+
// typing never flickers off between writes.
|
|
2429
|
+
export const TYPING_WINDOW_MS = 3000;
|
|
2430
|
+
|
|
2431
|
+
// Path to the Rust runner's per-pid stdin-activity marker — the tiny file it
|
|
2432
|
+
// stamps with the unix-ms of the user's last terminal keystroke (never `ay
|
|
2433
|
+
// send`/FIFO input). Mirrors rs/src/fifo.rs `stdin_activity_path`; a plain file
|
|
2434
|
+
// on all platforms (unlike the FIFO, which is a named pipe on Windows).
|
|
2435
|
+
export function stdinActivityPath(pid: number): string {
|
|
2436
|
+
return path.resolve(agentYesHome(), "activity", `${pid}.stdin`);
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
// Epoch-ms of the user's most recent keystroke at this agent's terminal, or
|
|
2440
|
+
// null if never/at rest. A missing or unparseable marker just means "not
|
|
2441
|
+
// typing" — this is a best-effort liveness hint, never a hard signal.
|
|
2442
|
+
export async function lastStdinAt(pid: number): Promise<number | null> {
|
|
2443
|
+
const raw = await readFile(stdinActivityPath(pid), "utf-8").catch(() => null);
|
|
2444
|
+
if (raw === null) return null;
|
|
2445
|
+
const ms = Number(raw.trim());
|
|
2446
|
+
return Number.isFinite(ms) && ms > 0 ? ms : null;
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// Whether the user typed at this agent's terminal within `windowMs`.
|
|
2450
|
+
export async function isUserTyping(pid: number, windowMs = TYPING_WINDOW_MS): Promise<boolean> {
|
|
2451
|
+
const at = await lastStdinAt(pid);
|
|
2452
|
+
return at !== null && Date.now() - at <= windowMs;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2143
2455
|
/**
|
|
2144
2456
|
* Whether an alive agent is wedged: its log has been silent for at least
|
|
2145
2457
|
* STUCK_THRESHOLD_MS yet its screen still shows a `working` busy marker (a live
|
|
@@ -2398,6 +2710,99 @@ export async function extractMenu(logPath: string, cli: string): Promise<MenuSta
|
|
|
2398
2710
|
return parseMenu(lines, { needsInput: cfg.needsInput, working: cfg.working });
|
|
2399
2711
|
}
|
|
2400
2712
|
|
|
2713
|
+
/**
|
|
2714
|
+
* Poll `logFile`'s size until it goes `quietMs` without changing, or `maxWaitMs`
|
|
2715
|
+
* total elapses (whichever first). Returns the final observed size, or null if
|
|
2716
|
+
* the file can't be stat'd. Used by `ay send` both to wait out a paste's render
|
|
2717
|
+
* (before submitting) and to detect whether a submit actually produced output
|
|
2718
|
+
* (after submitting).
|
|
2719
|
+
*/
|
|
2720
|
+
export async function waitForLogQuiet(
|
|
2721
|
+
logFile: string,
|
|
2722
|
+
quietMs: number,
|
|
2723
|
+
maxWaitMs: number,
|
|
2724
|
+
): Promise<number | null> {
|
|
2725
|
+
const pollMs = 50;
|
|
2726
|
+
let lastSize: number | null = null;
|
|
2727
|
+
let lastChangeAt = Date.now();
|
|
2728
|
+
const deadline = Date.now() + maxWaitMs;
|
|
2729
|
+
while (Date.now() < deadline) {
|
|
2730
|
+
const size = await stat(logFile)
|
|
2731
|
+
.then((s) => s.size)
|
|
2732
|
+
.catch(() => null);
|
|
2733
|
+
if (size === null) return null;
|
|
2734
|
+
if (size !== lastSize) {
|
|
2735
|
+
lastSize = size;
|
|
2736
|
+
lastChangeAt = Date.now();
|
|
2737
|
+
} else if (Date.now() - lastChangeAt >= quietMs) {
|
|
2738
|
+
return lastSize;
|
|
2739
|
+
}
|
|
2740
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
2741
|
+
}
|
|
2742
|
+
return lastSize;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
/**
|
|
2746
|
+
* Block while the user is typing at `pid`'s terminal, so `ay send` doesn't inject
|
|
2747
|
+
* mid-line. Polls the stdin-activity marker every SEND_TYPING_POLL_MS until the
|
|
2748
|
+
* user pauses (last keystroke older than the typing window) or `maxWaitMs`
|
|
2749
|
+
* elapses. Returns `{ clear, waitedMs }`: `clear` is true if they paused, false
|
|
2750
|
+
* if still typing at the deadline (caller sends anyway, with a warning).
|
|
2751
|
+
*/
|
|
2752
|
+
export async function backoffWhileTyping(
|
|
2753
|
+
pid: number,
|
|
2754
|
+
maxWaitMs: number,
|
|
2755
|
+
): Promise<{ clear: boolean; waitedMs: number }> {
|
|
2756
|
+
const start = Date.now();
|
|
2757
|
+
const deadline = start + maxWaitMs;
|
|
2758
|
+
let waited = false;
|
|
2759
|
+
while (Date.now() < deadline) {
|
|
2760
|
+
if (!(await isUserTyping(pid))) return { clear: true, waitedMs: waited ? Date.now() - start : 0 };
|
|
2761
|
+
waited = true;
|
|
2762
|
+
await new Promise((r) => setTimeout(r, SEND_TYPING_POLL_MS));
|
|
2763
|
+
}
|
|
2764
|
+
return { clear: false, waitedMs: Date.now() - start };
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
/**
|
|
2768
|
+
* Send the trailing submit code and confirm the CLI actually acted on it —
|
|
2769
|
+
* either a `working` busy marker appears, or the log grows meaningfully. Retries
|
|
2770
|
+
* (re-sending just the trailing code) up to SEND_SUBMIT_MAX_RETRIES times when
|
|
2771
|
+
* neither shows, since a swallowed Enter looks identical to a slow one until we
|
|
2772
|
+
* check. Returns whether submission was confirmed, plus the final rendered tail
|
|
2773
|
+
* (for a caller to show the user when it wasn't).
|
|
2774
|
+
*/
|
|
2775
|
+
export async function submitAndConfirm(
|
|
2776
|
+
record: GlobalPidRecord,
|
|
2777
|
+
fifoPath: string,
|
|
2778
|
+
trailing: string,
|
|
2779
|
+
): Promise<{ confirmed: boolean; screen: string[] }> {
|
|
2780
|
+
const logFile = record.log_file!;
|
|
2781
|
+
const cfg = (await cliDefaults())[record.cli];
|
|
2782
|
+
let screen: string[] = [];
|
|
2783
|
+
for (let attempt = 0; attempt <= SEND_SUBMIT_MAX_RETRIES; attempt++) {
|
|
2784
|
+
const sizeBefore =
|
|
2785
|
+
(await stat(logFile)
|
|
2786
|
+
.then((s) => s.size)
|
|
2787
|
+
.catch(() => null)) ?? 0;
|
|
2788
|
+
// A working marker already on screen BEFORE this attempt (e.g. a busy agent
|
|
2789
|
+
// that queues typed input) proves nothing about whether THIS Enter landed —
|
|
2790
|
+
// it could just be leftover from whatever the agent was already doing. Only
|
|
2791
|
+
// a working marker that WASN'T there before, or actual log growth, counts.
|
|
2792
|
+
const wasAlreadyWorking = isWorkingScreen(
|
|
2793
|
+
(await renderLogTailLines(logFile, 40)) ?? [],
|
|
2794
|
+
cfg?.working,
|
|
2795
|
+
);
|
|
2796
|
+
await writeToIpc(fifoPath, trailing);
|
|
2797
|
+
const sizeAfter = await waitForLogQuiet(logFile, SEND_CONFIRM_QUIET_MS, SEND_CONFIRM_MAX_MS);
|
|
2798
|
+
screen = (await renderLogTailLines(logFile, 40)) ?? [];
|
|
2799
|
+
const grew = sizeAfter !== null && sizeAfter >= sizeBefore + SEND_CONFIRM_MIN_GROWTH_BYTES;
|
|
2800
|
+
const nowWorking = isWorkingScreen(screen, cfg?.working);
|
|
2801
|
+
if ((nowWorking && !wasAlreadyWorking) || grew) return { confirmed: true, screen };
|
|
2802
|
+
}
|
|
2803
|
+
return { confirmed: false, screen };
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2401
2806
|
/** Poll until the agent is no longer parked on a menu (selection accepted → it
|
|
2402
2807
|
* resumed / moved on) or the deadline passes. Returns true if it cleared. */
|
|
2403
2808
|
async function waitForNeedsInputClear(
|
|
@@ -2415,6 +2820,12 @@ async function waitForNeedsInputClear(
|
|
|
2415
2820
|
|
|
2416
2821
|
async function cmdSend(rest: string[]): Promise<number> {
|
|
2417
2822
|
const y = yargs(rest)
|
|
2823
|
+
// Disable yargs' `--no-<flag>` negation: without this, `--no-wait` is parsed
|
|
2824
|
+
// as negating a phantom `wait` option (argv.wait=false) instead of setting our
|
|
2825
|
+
// explicitly-defined `no-wait`/`noWait` flag — so `--no-wait` silently did
|
|
2826
|
+
// nothing and still ran the (blocking) submit-confirm. The `--async` alias
|
|
2827
|
+
// masked this. No option here has a meaningful `--no-` form to lose.
|
|
2828
|
+
.parserConfiguration({ "boolean-negation": false })
|
|
2418
2829
|
.usage("Usage: ay send <keyword> <msg|-> [options]")
|
|
2419
2830
|
.option("code", {
|
|
2420
2831
|
type: "string",
|
|
@@ -2427,7 +2838,15 @@ async function cmdSend(rest: string[]): Promise<number> {
|
|
|
2427
2838
|
.option("force", {
|
|
2428
2839
|
type: "boolean",
|
|
2429
2840
|
default: false,
|
|
2430
|
-
description:
|
|
2841
|
+
description:
|
|
2842
|
+
"Skip the 'tailed recently' safety check and the wait-while-user-typing backoff (also: AGENT_YES_FORCE_SEND=1)",
|
|
2843
|
+
})
|
|
2844
|
+
.option("no-wait", {
|
|
2845
|
+
type: "boolean",
|
|
2846
|
+
default: false,
|
|
2847
|
+
alias: "async",
|
|
2848
|
+
description:
|
|
2849
|
+
"Fire-and-forget: skip the paste-settle wait and submit confirmation, don't retry a swallowed Enter (also: AGENT_YES_SEND_NO_WAIT=1)",
|
|
2431
2850
|
})
|
|
2432
2851
|
.help(false)
|
|
2433
2852
|
.version(false)
|
|
@@ -2502,15 +2921,66 @@ async function cmdSend(rest: string[]): Promise<number> {
|
|
|
2502
2921
|
: "";
|
|
2503
2922
|
|
|
2504
2923
|
const fullBody = prefix + body;
|
|
2924
|
+
const noWait = Boolean(argv.noWait) || process.env.AGENT_YES_SEND_NO_WAIT === "1";
|
|
2925
|
+
|
|
2926
|
+
// Back off while the user is typing at the target's terminal — injecting our
|
|
2927
|
+
// body mid-line fuses into their text and submits a mangled line. Only for a
|
|
2928
|
+
// real text body; skipped for --force (caller means it), --no-wait
|
|
2929
|
+
// (fire-and-forget), and empty bodies (a bare esc/ctrl-c interrupt is usually
|
|
2930
|
+
// intentional and time-sensitive). Sends anyway after the deadline so a
|
|
2931
|
+
// message is never silently dropped.
|
|
2932
|
+
if (fullBody && !noWait && !force) {
|
|
2933
|
+
const { clear, waitedMs } = await backoffWhileTyping(record.pid, SEND_TYPING_MAX_WAIT_MS);
|
|
2934
|
+
if (!clear) {
|
|
2935
|
+
process.stderr.write(
|
|
2936
|
+
`warning: user still typing at pid ${record.pid} after ${Math.round(waitedMs / 1000)}s — ` +
|
|
2937
|
+
`sending anyway (may interleave with their line). Use --force to skip this wait.\n`,
|
|
2938
|
+
);
|
|
2939
|
+
} else if (waitedMs > 0) {
|
|
2940
|
+
process.stderr.write(
|
|
2941
|
+
`waited ${Math.round(waitedMs / 1000)}s for the user to pause typing before sending.\n`,
|
|
2942
|
+
);
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
// Submit-confirm only applies to an actual submit (Enter/CR) with a body and a
|
|
2946
|
+
// log to watch — other trailing codes (esc/ctrl-c/tab/none) don't have a "did
|
|
2947
|
+
// it land" signal in the same sense, and retrying e.g. ctrl-c could
|
|
2948
|
+
// double-interrupt. Checked against the resolved byte, not the code NAME, so
|
|
2949
|
+
// every alias that resolves to Enter (--code=enter or --code=cr) is covered.
|
|
2950
|
+
const canConfirm = trailing === "\r" && Boolean(fullBody) && !noWait;
|
|
2951
|
+
let confirmed = true;
|
|
2952
|
+
let lastScreen: string[] = [];
|
|
2505
2953
|
if (fullBody && trailing) {
|
|
2506
2954
|
await writeToIpc(fifoPath, fullBody);
|
|
2507
|
-
|
|
2508
|
-
|
|
2955
|
+
if (canConfirm && record.log_file) {
|
|
2956
|
+
// Wait for the paste to actually finish rendering — a long/multi-line body
|
|
2957
|
+
// can take longer than any fixed guess, and sending Enter mid-paste gets
|
|
2958
|
+
// swallowed by the CLI's bracketed-paste handling instead of submitting.
|
|
2959
|
+
await waitForLogQuiet(record.log_file, SEND_SETTLE_QUIET_MS, SEND_SETTLE_MAX_MS);
|
|
2960
|
+
({ confirmed, screen: lastScreen } = await submitAndConfirm(record, fifoPath, trailing));
|
|
2961
|
+
} else {
|
|
2962
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
2963
|
+
await writeToIpc(fifoPath, trailing);
|
|
2964
|
+
}
|
|
2509
2965
|
} else {
|
|
2510
2966
|
await writeToIpc(fifoPath, fullBody + trailing);
|
|
2511
2967
|
}
|
|
2512
2968
|
const payload = body + trailing;
|
|
2513
|
-
|
|
2969
|
+
const status = confirmed ? "sent" : "sent but NOT confirmed submitted";
|
|
2970
|
+
process.stdout.write(
|
|
2971
|
+
`${status} to pid ${record.pid} (${record.cli}): ${truncate(payload, 80)}\n`,
|
|
2972
|
+
);
|
|
2973
|
+
if (!confirmed) {
|
|
2974
|
+
process.stderr.write(
|
|
2975
|
+
`\nwarning: couldn't confirm the CLI acted on it after ${SEND_SUBMIT_MAX_RETRIES + 1} attempt(s) — ` +
|
|
2976
|
+
`it may still be sitting unsubmitted in the prompt. Last screen:\n` +
|
|
2977
|
+
lastScreen
|
|
2978
|
+
.slice(-8)
|
|
2979
|
+
.map((l) => ` ${l}`)
|
|
2980
|
+
.join("\n") +
|
|
2981
|
+
"\n",
|
|
2982
|
+
);
|
|
2983
|
+
}
|
|
2514
2984
|
|
|
2515
2985
|
const replyHint = sender.agent
|
|
2516
2986
|
? ` ay send ${sender.agent.pid} "..." # reply to sender\n`
|
|
@@ -2525,7 +2995,7 @@ async function cmdSend(rest: string[]): Promise<number> {
|
|
|
2525
2995
|
const tip = stopTipForCli(record.cli, record.pid);
|
|
2526
2996
|
if (tip) process.stderr.write(tip);
|
|
2527
2997
|
}
|
|
2528
|
-
return 0;
|
|
2998
|
+
return confirmed ? 0 : 1;
|
|
2529
2999
|
}
|
|
2530
3000
|
|
|
2531
3001
|
// Resolve a keyword to one agent and return it with a writable FIFO, or throw
|
|
@@ -3878,3 +4348,268 @@ async function cmdResultSet(rest: string[]): Promise<number> {
|
|
|
3878
4348
|
process.stdout.write(`result envelope written for pid ${pid}\n`);
|
|
3879
4349
|
return 0;
|
|
3880
4350
|
}
|
|
4351
|
+
|
|
4352
|
+
// ---------------------------------------------------------------------------
|
|
4353
|
+
// ay notify / ay notifyd — subagent→parent status-transition notifications.
|
|
4354
|
+
//
|
|
4355
|
+
// See docs/subagent-notify.md. `ay notifyd` is the detection engine (query-layer
|
|
4356
|
+
// watcher, runtime-agnostic); `ay notify` is the parent-facing inbox reader. A
|
|
4357
|
+
// parent typically runs ONE command in its Monitor loop:
|
|
4358
|
+
//
|
|
4359
|
+
// ay notify watch --unread # tail its inbox, ensure the daemon
|
|
4360
|
+
//
|
|
4361
|
+
// and gets every child's needs_input / sustained-idle / exited edge, each with a
|
|
4362
|
+
// payload (question / tail / git head) so it can act without tailing the child.
|
|
4363
|
+
// ---------------------------------------------------------------------------
|
|
4364
|
+
|
|
4365
|
+
/** Resolve the parent pid a `ay notify` invocation is draining. */
|
|
4366
|
+
function resolveParentPid(explicit: number | undefined): number {
|
|
4367
|
+
if (Number.isFinite(explicit) && (explicit as number) > 0) return explicit as number;
|
|
4368
|
+
const self = Number(process.env.AGENT_YES_PID);
|
|
4369
|
+
if (Number.isFinite(self) && self > 0) return self;
|
|
4370
|
+
throw new Error(
|
|
4371
|
+
"ay notify: not running inside an agent (no AGENT_YES_PID) — pass --parent <pid>",
|
|
4372
|
+
);
|
|
4373
|
+
}
|
|
4374
|
+
|
|
4375
|
+
function printNotifyEvents(events: NotifyEvent[], json: boolean): void {
|
|
4376
|
+
if (json) {
|
|
4377
|
+
for (const e of events) process.stdout.write(JSON.stringify(e) + "\n");
|
|
4378
|
+
return;
|
|
4379
|
+
}
|
|
4380
|
+
for (const e of events) {
|
|
4381
|
+
// Plain ASCII tag (no emoji): stays legible over the Rust/CLI path and old
|
|
4382
|
+
// terminals, and is easy to grep for consumers that log-parse the stream.
|
|
4383
|
+
const tag = `[${e.edge}]`;
|
|
4384
|
+
const head = `[${e.seq}] ${tag} pid ${e.child_pid} (${e.cli}) ${e.cwd}`;
|
|
4385
|
+
process.stdout.write(head + "\n");
|
|
4386
|
+
if (e.git_head) process.stdout.write(` HEAD ${e.git_head}\n`);
|
|
4387
|
+
if (e.question) process.stdout.write(` Q: ${e.question}\n`);
|
|
4388
|
+
if (e.tail)
|
|
4389
|
+
process.stdout.write(
|
|
4390
|
+
e.tail
|
|
4391
|
+
.split("\n")
|
|
4392
|
+
.map((l) => ` | ${l}`)
|
|
4393
|
+
.join("\n") + "\n",
|
|
4394
|
+
);
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
|
|
4398
|
+
async function cmdNotify(rest: string[]): Promise<number> {
|
|
4399
|
+
const verb = rest[0];
|
|
4400
|
+
const args = rest.slice(1);
|
|
4401
|
+
|
|
4402
|
+
if (verb === "cursor") return cmdNotifyCursor(args);
|
|
4403
|
+
if (verb !== "read" && verb !== "watch") {
|
|
4404
|
+
process.stderr.write(
|
|
4405
|
+
"usage: ay notify <read|watch|cursor> [--parent <pid>] [--since <seq>] [--since-ts <ms>] [--unread] [--ack] [--json]\n",
|
|
4406
|
+
);
|
|
4407
|
+
return 1;
|
|
4408
|
+
}
|
|
4409
|
+
|
|
4410
|
+
const y = yargs(args)
|
|
4411
|
+
.option("parent", { type: "number", description: "Parent pid whose inbox to drain (default: $AGENT_YES_PID)" })
|
|
4412
|
+
.option("since", { type: "number", description: "Only edges with seq greater than this" })
|
|
4413
|
+
.option("since-ts", { type: "number", description: "Only edges at/after this epoch-ms" })
|
|
4414
|
+
.option("unread", { type: "boolean", default: false, description: "Only edges past the saved cursor" })
|
|
4415
|
+
.option("ack", { type: "boolean", default: false, description: "Advance the cursor past what's shown (at-least-once: off by default)" })
|
|
4416
|
+
.option("json", { type: "boolean", default: false, description: "Emit raw NDJSON events" })
|
|
4417
|
+
.option("consumer", { type: "string", default: "parent", description: "Cursor identity (for multiple readers)" })
|
|
4418
|
+
.option("interval", { type: "number", default: 2, description: "Poll interval in seconds (watch)" })
|
|
4419
|
+
.option("ensure-daemon", { type: "boolean", default: true, description: "Start the notifyd singleton if not running (watch)" })
|
|
4420
|
+
.help(false)
|
|
4421
|
+
.version(false)
|
|
4422
|
+
.exitProcess(false);
|
|
4423
|
+
const argv = await y.parseAsync();
|
|
4424
|
+
|
|
4425
|
+
const parent = resolveParentPid(argv.parent as number | undefined);
|
|
4426
|
+
const host = hostId();
|
|
4427
|
+
const consumer = String(argv.consumer);
|
|
4428
|
+
// The reader's own start time — used to reject inbox events addressed to a
|
|
4429
|
+
// PRIOR incarnation of this pid (pid reuse). FAIL-CLOSED: if we can't resolve
|
|
4430
|
+
// the parent's identity (no live registry record → started_at 0), we refuse to
|
|
4431
|
+
// open the notification path at all rather than fail-open and risk delivering a
|
|
4432
|
+
// recycled pid's inbox to an unrelated agent (or registering a 0-identity
|
|
4433
|
+
// watcher). "If we don't know who the parent is, don't open the path."
|
|
4434
|
+
const selfStartedAt = await resolveParentStartedAt(parent);
|
|
4435
|
+
if (selfStartedAt <= 0)
|
|
4436
|
+
throw new Error(
|
|
4437
|
+
`cannot resolve identity for pid ${parent} (no live agent record) — ` +
|
|
4438
|
+
`refusing to open the notification path (pass --parent for a live agent).`,
|
|
4439
|
+
);
|
|
4440
|
+
|
|
4441
|
+
const drain = async (sinceSeqOverride?: number): Promise<number> => {
|
|
4442
|
+
let events = await readInbox(host, parent);
|
|
4443
|
+
// Parent pid-reuse guard (fail-safe): when we know our own start time, deliver
|
|
4444
|
+
// ONLY events whose parent_started_at EXACTLY matches it — a mismatched OR
|
|
4445
|
+
// missing parent identity is dropped, never fail-open-delivered to a possibly-
|
|
4446
|
+
// recycled pid. The daemon always stamps parent_started_at from the watcher's
|
|
4447
|
+
// heartbeat (the same value this reader resolves), so every legitimate event
|
|
4448
|
+
// matches; only truly-legacy identity-less events fall out.
|
|
4449
|
+
if (selfStartedAt > 0) events = events.filter((e) => e.parent_started_at === selfStartedAt);
|
|
4450
|
+
if (argv.unread) {
|
|
4451
|
+
const cursor = await getCursor(host, parent, consumer);
|
|
4452
|
+
events = filterUnread(events, sinceSeqOverride ?? cursor);
|
|
4453
|
+
} else {
|
|
4454
|
+
if (sinceSeqOverride !== undefined) events = filterSinceSeq(events, sinceSeqOverride);
|
|
4455
|
+
else if (Number.isFinite(argv.since)) events = filterSinceSeq(events, argv.since as number);
|
|
4456
|
+
if (Number.isFinite(argv["since-ts"])) events = filterSinceTs(events, argv["since-ts"] as number);
|
|
4457
|
+
}
|
|
4458
|
+
printNotifyEvents(events, argv.json);
|
|
4459
|
+
return maxSeq(events);
|
|
4460
|
+
};
|
|
4461
|
+
|
|
4462
|
+
// Advance the cursor MONOTONICALLY — never below its current value, and never
|
|
4463
|
+
// regressing on an empty batch. This is what makes `watch --ack` safe across a
|
|
4464
|
+
// consumer restart: the high-water of what we've shown is always persisted.
|
|
4465
|
+
const ackTo = async (seq: number) => {
|
|
4466
|
+
const cur = await getCursor(host, parent, consumer);
|
|
4467
|
+
if (seq > cur) await setCursor(host, parent, seq, consumer);
|
|
4468
|
+
};
|
|
4469
|
+
|
|
4470
|
+
if (verb === "read") {
|
|
4471
|
+
const top = await drain();
|
|
4472
|
+
if (argv.ack && top > 0) await ackTo(top);
|
|
4473
|
+
return 0;
|
|
4474
|
+
}
|
|
4475
|
+
|
|
4476
|
+
// watch: tail -f the inbox. Default no-ack (at-least-once) so a consumer that
|
|
4477
|
+
// crashes mid-handling re-reads on restart; pass --ack to advance the cursor.
|
|
4478
|
+
const ensure = async () => {
|
|
4479
|
+
if (!argv["ensure-daemon"]) return;
|
|
4480
|
+
const { ensureDaemon } = await import("./notifyDaemon.ts");
|
|
4481
|
+
await ensureDaemon().catch(() => null);
|
|
4482
|
+
};
|
|
4483
|
+
// Register this parent as a live watcher BEFORE the first poll and ensure a
|
|
4484
|
+
// daemon exists — so a parent that watches *before* spawning any child (or
|
|
4485
|
+
// across a fan-out gap) still has a running, correctly-scoped daemon.
|
|
4486
|
+
await heartbeatWatcher(parent, selfStartedAt);
|
|
4487
|
+
await ensure();
|
|
4488
|
+
const intervalMs = Math.max(500, (Number.isFinite(argv.interval) ? argv.interval : 2) * 1000);
|
|
4489
|
+
// Baseline: from the cursor (unread) or the caller's --since, else from now
|
|
4490
|
+
// (only new edges). Track high-water seq in-memory between polls.
|
|
4491
|
+
let lastSeq = argv.unread
|
|
4492
|
+
? await getCursor(host, parent, consumer)
|
|
4493
|
+
: Number.isFinite(argv.since)
|
|
4494
|
+
? (argv.since as number)
|
|
4495
|
+
: maxSeq(await readInbox(host, parent));
|
|
4496
|
+
let acked = lastSeq; // high-water already persisted to the cursor
|
|
4497
|
+
let stop = false;
|
|
4498
|
+
// On signal: drop our heartbeat and exit promptly. (Even if this is missed on
|
|
4499
|
+
// a hard kill, the watcher's TTL makes the stale heartbeat non-live, so the
|
|
4500
|
+
// daemon's scope/self-exit stays correct — this is just prompt cleanup.)
|
|
4501
|
+
const onSig = () => {
|
|
4502
|
+
stop = true;
|
|
4503
|
+
void clearWatcher(parent).finally(() => process.exit(0));
|
|
4504
|
+
};
|
|
4505
|
+
process.on("SIGINT", onSig);
|
|
4506
|
+
process.on("SIGTERM", onSig);
|
|
4507
|
+
try {
|
|
4508
|
+
while (!stop) {
|
|
4509
|
+
// Refresh our heartbeat and keep the daemon alive every tick — it self-
|
|
4510
|
+
// exits after a grace window with no watchers, so a long watch must renew.
|
|
4511
|
+
await heartbeatWatcher(parent, selfStartedAt);
|
|
4512
|
+
await ensure();
|
|
4513
|
+
const top = await drain(lastSeq);
|
|
4514
|
+
if (top > lastSeq) lastSeq = top;
|
|
4515
|
+
// Persist the high-water monotonically (only when it advanced), so a batch
|
|
4516
|
+
// that showed events is acked even if the NEXT poll is empty — a restarted
|
|
4517
|
+
// `watch --ack` then resumes past what it already delivered, not from the
|
|
4518
|
+
// stale cursor.
|
|
4519
|
+
if (argv.ack && lastSeq > acked) {
|
|
4520
|
+
await ackTo(lastSeq);
|
|
4521
|
+
acked = lastSeq;
|
|
4522
|
+
}
|
|
4523
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
4524
|
+
}
|
|
4525
|
+
} finally {
|
|
4526
|
+
await clearWatcher(parent);
|
|
4527
|
+
}
|
|
4528
|
+
return 0;
|
|
4529
|
+
}
|
|
4530
|
+
|
|
4531
|
+
/**
|
|
4532
|
+
* Resolve the started_at of the LIVE agent whose wrapper pid is `parent`. Returns
|
|
4533
|
+
* 0 (→ the caller fails closed) when there is no such record, when the matching
|
|
4534
|
+
* record is stale (exited / pid not alive), or when the match is ambiguous
|
|
4535
|
+
* (>1 live record) — so a leftover stale record can't make a recycled parent pid
|
|
4536
|
+
* resolve to a PRIOR incarnation's start time and fail-open the identity guard.
|
|
4537
|
+
*/
|
|
4538
|
+
async function resolveParentStartedAt(parent: number): Promise<number> {
|
|
4539
|
+
const records = await listRecords(undefined, {
|
|
4540
|
+
all: true,
|
|
4541
|
+
active: false,
|
|
4542
|
+
json: false,
|
|
4543
|
+
latest: false,
|
|
4544
|
+
cwdScope: null,
|
|
4545
|
+
}).catch(() => [] as GlobalPidRecord[]);
|
|
4546
|
+
const live = records.filter(
|
|
4547
|
+
(r) =>
|
|
4548
|
+
(r.wrapper_pid === parent || r.pid === parent) &&
|
|
4549
|
+
r.status !== "exited" &&
|
|
4550
|
+
isPidAlive(r.pid),
|
|
4551
|
+
);
|
|
4552
|
+
// Exactly one live match, or fail closed.
|
|
4553
|
+
if (live.length !== 1) return 0;
|
|
4554
|
+
return live[0]!.started_at ?? 0;
|
|
4555
|
+
}
|
|
4556
|
+
|
|
4557
|
+
async function cmdNotifyCursor(args: string[]): Promise<number> {
|
|
4558
|
+
const action = args[0];
|
|
4559
|
+
const y = yargs(args.slice(1))
|
|
4560
|
+
.option("parent", { type: "number" })
|
|
4561
|
+
.option("consumer", { type: "string", default: "parent" })
|
|
4562
|
+
.help(false)
|
|
4563
|
+
.version(false)
|
|
4564
|
+
.exitProcess(false);
|
|
4565
|
+
const argv = await y.parseAsync();
|
|
4566
|
+
const parent = resolveParentPid(argv.parent as number | undefined);
|
|
4567
|
+
const host = hostId();
|
|
4568
|
+
const consumer = String(argv.consumer);
|
|
4569
|
+
if (action === "get") {
|
|
4570
|
+
process.stdout.write(String(await getCursor(host, parent, consumer)) + "\n");
|
|
4571
|
+
return 0;
|
|
4572
|
+
}
|
|
4573
|
+
if (action === "set") {
|
|
4574
|
+
const seq = Number(argv._[0]);
|
|
4575
|
+
if (!Number.isFinite(seq) || seq < 0) throw new Error("ay notify cursor set <seq>");
|
|
4576
|
+
await setCursor(host, parent, seq, consumer);
|
|
4577
|
+
return 0;
|
|
4578
|
+
}
|
|
4579
|
+
process.stderr.write("usage: ay notify cursor <get|set <seq>> [--parent <pid>]\n");
|
|
4580
|
+
return 1;
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
async function cmdNotifyd(rest: string[]): Promise<number> {
|
|
4584
|
+
const sub = rest[0] ?? "status";
|
|
4585
|
+
const daemon = await import("./notifyDaemon.ts");
|
|
4586
|
+
switch (sub) {
|
|
4587
|
+
case "run":
|
|
4588
|
+
return daemon.runDaemon();
|
|
4589
|
+
case "once":
|
|
4590
|
+
return daemon.runDaemon({ once: true });
|
|
4591
|
+
case "start": {
|
|
4592
|
+
const pid = await daemon.ensureDaemon();
|
|
4593
|
+
process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: failed to start\n");
|
|
4594
|
+
return pid ? 0 : 1;
|
|
4595
|
+
}
|
|
4596
|
+
case "status": {
|
|
4597
|
+
const pid = await daemon.daemonStatus();
|
|
4598
|
+
process.stdout.write(pid ? `notifyd running (pid ${pid})\n` : "notifyd: not running\n");
|
|
4599
|
+
return pid ? 0 : 1;
|
|
4600
|
+
}
|
|
4601
|
+
case "stop": {
|
|
4602
|
+
// Cooperative, non-destructive stop: remove the daemon's lock and let it
|
|
4603
|
+
// exit itself on the next tick — never SIGTERM a pid that may have been
|
|
4604
|
+
// recycled onto an unrelated process.
|
|
4605
|
+
const pid = await daemon.requestDaemonStop();
|
|
4606
|
+
process.stdout.write(
|
|
4607
|
+
pid ? `notifyd: stop requested (pid ${pid} will exit shortly)\n` : "notifyd: not running\n",
|
|
4608
|
+
);
|
|
4609
|
+
return 0;
|
|
4610
|
+
}
|
|
4611
|
+
default:
|
|
4612
|
+
process.stderr.write("usage: ay notifyd <run|once|start|status|stop>\n");
|
|
4613
|
+
return 1;
|
|
4614
|
+
}
|
|
4615
|
+
}
|