agent-yes 1.122.2 → 1.123.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.
Files changed (56) hide show
  1. package/default.config.yaml +19 -0
  2. package/dist/{SUPPORTED_CLIS-BTu2brih.js → SUPPORTED_CLIS-B4O2cFlt.js} +2 -2
  3. package/dist/SUPPORTED_CLIS-DHkqGoNv.js +8 -0
  4. package/dist/{agent-yes.config-z-IPzH5U.js → agent-yes.config-D6ycMApr.js} +2 -65
  5. package/dist/cli.js +6 -6
  6. package/dist/configShared-C5QaNPnz.js +71 -0
  7. package/dist/{globalPidIndex-gZuTvTBs.js → globalPidIndex-C7r2m6s7.js} +19 -20
  8. package/dist/index.js +4 -4
  9. package/dist/pidStore-C4c2O15q.js +5 -0
  10. package/dist/{pidStore-B5vBu8Px.js → pidStore-CGKIhaJO.js} +5 -4
  11. package/dist/reaper-BLVA780B.js +3 -0
  12. package/dist/{reaper-Dj8R7ltI.js → reaper-BkjPN7mw.js} +24 -2
  13. package/dist/{remotes-CpGcTr7A.js → remotes-BRCDVnR7.js} +1 -1
  14. package/dist/{remotes-D2fqaRU8.js → remotes-D8GvSbhf.js} +1 -1
  15. package/dist/{schedule-DgRrdA_n.js → schedule-DULdIkU9.js} +7 -7
  16. package/dist/{serve-tn7ZetZs.js → serve-r_2v9EKc.js} +202 -58
  17. package/dist/{setup-dZhgpNse.js → setup-DHa6fX8M.js} +3 -3
  18. package/dist/{share-CksllWW-.js → share-YuM6-Q6A.js} +78 -4
  19. package/dist/{subcommands-D9BWZilr.js → subcommands-B13Kto-u.js} +647 -32
  20. package/dist/subcommands-Tv6AwUkD.js +7 -0
  21. package/dist/{tray-DjCIyakK.js → tray-BVnJLThD.js} +1 -1
  22. package/dist/{ts-CIf0uaR7.js → ts-DgukRoEI.js} +10 -7
  23. package/dist/{versionChecker-DjxKi4qe.js → versionChecker-BqOr1YqC.js} +2 -2
  24. package/dist/{workspaceConfig-XP2NEWmV.js → workspaceConfig-BJO4fzEn.js} +1 -1
  25. package/lab/ui/console-logic.js +222 -10
  26. package/lab/ui/icon.svg +5 -0
  27. package/lab/ui/index.html +689 -14
  28. package/lab/ui/landing.html +276 -0
  29. package/lab/ui/manifest.webmanifest +14 -0
  30. package/lab/ui/sw.js +56 -0
  31. package/package.json +5 -1
  32. package/ts/agentTree.spec.ts +92 -0
  33. package/ts/agentTree.ts +149 -0
  34. package/ts/configShared.ts +4 -0
  35. package/ts/globalPidIndex.ts +28 -20
  36. package/ts/idleWaiter.spec.ts +7 -1
  37. package/ts/index.ts +9 -0
  38. package/ts/lsWatch.spec.ts +61 -0
  39. package/ts/lsWatch.ts +94 -0
  40. package/ts/needsInput.spec.ts +55 -0
  41. package/ts/needsInput.ts +68 -0
  42. package/ts/pidStore.ts +3 -0
  43. package/ts/reaper.spec.ts +26 -2
  44. package/ts/reaper.ts +25 -0
  45. package/ts/resultEnvelope.spec.ts +43 -0
  46. package/ts/resultEnvelope.ts +88 -0
  47. package/ts/serve.ts +276 -41
  48. package/ts/share.ts +156 -3
  49. package/ts/subcommands.ts +0 -0
  50. package/ts/todoParse.spec.ts +68 -0
  51. package/ts/todoParse.ts +88 -0
  52. package/ts/utils.spec.ts +4 -1
  53. package/dist/SUPPORTED_CLIS-DcOKE9Nz.js +0 -8
  54. package/dist/pidStore-7y1cTcAE.js +0 -5
  55. package/dist/reaper-HqcUms2d.js +0 -3
  56. package/dist/subcommands-D8sHibKu.js +0 -6
@@ -41,6 +41,12 @@ export interface GlobalPidRecord {
41
41
  // known until after spawn), so this maps that env value back to the agent's
42
42
  // canonical record — see resolveSender() in subcommands.ts.
43
43
  wrapper_pid?: number | null;
44
+ // The AGENT_YES_PID this wrapper *inherited* from its own environment when it
45
+ // started — i.e. the wrapper_pid of the PARENT agent that spawned this one (a
46
+ // nested `ay` launched from inside another agent). Null for top-level agents
47
+ // started from a human shell. Builds the agent>subagent tree: a child links to
48
+ // its parent via child.parent_pid === parent.wrapper_pid. See buildAgentForest.
49
+ parent_pid?: number | null;
44
50
  }
45
51
 
46
52
  /**
@@ -60,14 +66,13 @@ export function getGlobalPidIndexPath(): string {
60
66
  return resolveGlobalFile();
61
67
  }
62
68
 
63
- async function ensureDir() {
64
- await mkdir(resolveGlobalDir(), { recursive: true });
65
- }
66
-
67
- async function withLock<R>(fn: () => Promise<R>): Promise<R> {
68
- await ensureDir();
69
- const file = resolveGlobalFile();
70
- const dir = resolveGlobalDir();
69
+ // Locks/operates on the `file` the CALLER resolved (at call time). Resolving the
70
+ // path up front — before any await keeps fire-and-forget writes from landing in
71
+ // a different ~/.agent-yes if AGENT_YES_HOME changes before the async write runs
72
+ // (notably tests that set+reset it around an un-awaited mirror write).
73
+ async function withLock<R>(file: string, fn: () => Promise<R>): Promise<R> {
74
+ const dir = path.dirname(file);
75
+ await mkdir(dir, { recursive: true });
71
76
  let release: (() => Promise<void>) | undefined;
72
77
  try {
73
78
  release = await lock(dir, {
@@ -82,9 +87,10 @@ async function withLock<R>(fn: () => Promise<R>): Promise<R> {
82
87
 
83
88
  /** Append one full record line. Caller must provide all required fields. */
84
89
  export async function appendGlobalPid(record: GlobalPidRecord): Promise<void> {
90
+ const file = resolveGlobalFile(); // capture at call time (see withLock)
85
91
  try {
86
- await withLock(async () => {
87
- await appendFile(resolveGlobalFile(), JSON.stringify(record) + "\n");
92
+ await withLock(file, async () => {
93
+ await appendFile(file, JSON.stringify(record) + "\n");
88
94
  });
89
95
  } catch (error) {
90
96
  logger.debug("[globalPidIndex] append failed:", error);
@@ -96,13 +102,14 @@ export async function updateGlobalPidStatus(
96
102
  pid: number,
97
103
  patch: Partial<Pick<GlobalPidRecord, "status" | "exit_code" | "exit_reason" | "log_file">>,
98
104
  ): Promise<void> {
105
+ const file = resolveGlobalFile(); // capture at call time (see withLock)
99
106
  try {
100
- await withLock(async () => {
101
- const current = await readGlobalPidsRaw();
107
+ await withLock(file, async () => {
108
+ const current = await readGlobalPidsRaw(file);
102
109
  const existing = current.find((r) => r.pid === pid);
103
110
  if (!existing) return; // unknown pid — nothing to update
104
111
  const merged: GlobalPidRecord = { ...existing, ...patch };
105
- await appendFile(resolveGlobalFile(), JSON.stringify(merged) + "\n");
112
+ await appendFile(file, JSON.stringify(merged) + "\n");
106
113
  });
107
114
  } catch (error) {
108
115
  logger.debug("[globalPidIndex] updateStatus failed:", error);
@@ -112,10 +119,10 @@ export async function updateGlobalPidStatus(
112
119
  /**
113
120
  * Read the file once without merge logic — internal helper for status updates.
114
121
  */
115
- async function readGlobalPidsRaw(): Promise<GlobalPidRecord[]> {
122
+ async function readGlobalPidsRaw(file: string = resolveGlobalFile()): Promise<GlobalPidRecord[]> {
116
123
  let raw: string;
117
124
  try {
118
- raw = await readFile(resolveGlobalFile(), "utf-8");
125
+ raw = await readFile(file, "utf-8");
119
126
  } catch (err: any) {
120
127
  if (err.code === "ENOENT") return [];
121
128
  throw err;
@@ -169,9 +176,10 @@ const COMPACT_THRESHOLD_LINES = 500; // raw events; one merged record per pid
169
176
  * it no-ops when the file is already small enough.
170
177
  */
171
178
  export async function maybeCompactGlobalPids(): Promise<void> {
179
+ const file = resolveGlobalFile(); // capture at call time (see withLock)
172
180
  let raw: string;
173
181
  try {
174
- raw = await readFile(resolveGlobalFile(), "utf-8");
182
+ raw = await readFile(file, "utf-8");
175
183
  } catch (err: any) {
176
184
  if (err.code === "ENOENT") return;
177
185
  return;
@@ -180,15 +188,15 @@ export async function maybeCompactGlobalPids(): Promise<void> {
180
188
  if (lineCount < COMPACT_THRESHOLD_LINES) return;
181
189
 
182
190
  try {
183
- await withLock(async () => {
184
- const merged = await readGlobalPidsRaw();
191
+ await withLock(file, async () => {
192
+ const merged = await readGlobalPidsRaw(file);
185
193
  // Drop dead-and-exited entries; keep dead-but-not-yet-exited so a later
186
194
  // status-update from elsewhere can still be matched against them.
187
195
  const keep = merged.filter((r) => r.status !== "exited" || isProcessAlive(r.pid));
188
- const tmpFile = resolveGlobalFile() + ".compact";
196
+ const tmpFile = file + ".compact";
189
197
  const content = keep.map((r) => JSON.stringify(r)).join("\n") + (keep.length ? "\n" : "");
190
198
  await writeFile(tmpFile, content);
191
- await rename(tmpFile, resolveGlobalFile());
199
+ await rename(tmpFile, file);
192
200
  logger.debug(`[globalPidIndex] compacted ${lineCount} → ${keep.length} lines`);
193
201
  });
194
202
  } catch (error) {
@@ -3,8 +3,14 @@ import { IdleWaiter } from "./idleWaiter";
3
3
 
4
4
  describe("IdleWaiter", () => {
5
5
  it("should initialize with current time", () => {
6
+ // Bracket the constructor instead of a magic toBeCloseTo tolerance: the
7
+ // timestamp must fall within [before, after], which can't flake on a slow CI
8
+ // runner (a GC pause would just widen the bracket, never break it).
9
+ const before = Date.now();
6
10
  const waiter = new IdleWaiter();
7
- expect(waiter.lastActivityTime).toBeCloseTo(Date.now(), -2);
11
+ const after = Date.now();
12
+ expect(waiter.lastActivityTime).toBeGreaterThanOrEqual(before);
13
+ expect(waiter.lastActivityTime).toBeLessThanOrEqual(after);
8
14
  });
9
15
 
10
16
  it("should update lastActivityTime when ping is called", () => {
package/ts/index.ts CHANGED
@@ -65,6 +65,7 @@ export type AgentCliConfig = {
65
65
  enterExclude?: RegExp[]; // array of regex to exclude from auto-enter (even if enter matches)
66
66
  typingRespond?: { [message: string]: RegExp[] }; // type specified message to a specified pattern
67
67
  autoRetry?: RegExp[]; // recoverable API errors (overload/rate-limit/usage-limit): type "retry" with exponential backoff (up to 8h) instead of exiting
68
+ needsInput?: RegExp[]; // agent is blocked on an interactive selection menu it did NOT auto-resolve; surfaced as the `needs_input` state by `ay ls`/`ay status` (query-time only — not consumed by the run loop)
68
69
 
69
70
  // crash/resuming-session behaviour
70
71
  restoreArgs?: string[]; // arguments to continue the session when crashed
@@ -350,6 +351,12 @@ export default async function agentYes({
350
351
 
351
352
  // Spawn the agent CLI process
352
353
  const ptyEnv = { ...(env ?? (process.env as Record<string, string>)) };
354
+ // Capture the AGENT_YES_PID we INHERITED (the wrapper of the parent agent that
355
+ // launched this nested `ay`) before we overwrite it with our own pid below.
356
+ // null when started from a human shell → this agent is a tree root.
357
+ const inheritedAyPid = Number(ptyEnv.AGENT_YES_PID);
358
+ const parentPid =
359
+ Number.isInteger(inheritedAyPid) && inheritedAyPid > 0 ? inheritedAyPid : undefined;
353
360
  ptyEnv.AGENT_YES_PID = String(process.pid);
354
361
  const ptyOptions = {
355
362
  name: "xterm-color",
@@ -390,6 +397,8 @@ export default async function agentYes({
390
397
  // We inject our own pid as AGENT_YES_PID into the agent's env above; record
391
398
  // it so a child `ay send` can map that env value back to this agent.
392
399
  wrapperPid: process.pid,
400
+ // The parent agent's wrapper pid (inherited AGENT_YES_PID), for the tree.
401
+ parentPid,
393
402
  });
394
403
  } catch (error) {
395
404
  logger.warn(`[pidStore] Failed to register process ${shell.pid}:`, error);
@@ -0,0 +1,61 @@
1
+ import { expect, test } from "vitest";
2
+ import { diffLsStates, type LsAgentState } from "./lsWatch.ts";
3
+
4
+ const agent = (
5
+ pid: number,
6
+ state: LsAgentState["state"],
7
+ question: string | null = null,
8
+ ): LsAgentState => ({
9
+ pid,
10
+ cli: "claude",
11
+ cwd: "/repo",
12
+ state,
13
+ question,
14
+ });
15
+
16
+ test("first observation of each agent is a baseline event (prev_state null)", () => {
17
+ const { events, next } = diffLsStates(new Map(), [agent(1, "active"), agent(2, "idle")], 100);
18
+ expect(events).toHaveLength(2);
19
+ expect(events.every((e) => e.prev_state === null)).toBe(true);
20
+ expect(next.size).toBe(2);
21
+ });
22
+
23
+ test("no event when nothing changed", () => {
24
+ const prev = new Map([[1, agent(1, "active")]]);
25
+ const { events } = diffLsStates(prev, [agent(1, "active")], 200);
26
+ expect(events).toHaveLength(0);
27
+ });
28
+
29
+ test("emits a transition when state changes (active -> needs_input)", () => {
30
+ const prev = new Map([[1, agent(1, "active")]]);
31
+ const { events } = diffLsStates(prev, [agent(1, "needs_input", "Pick auth?")], 300);
32
+ expect(events).toHaveLength(1);
33
+ expect(events[0]).toMatchObject({
34
+ pid: 1,
35
+ state: "needs_input",
36
+ question: "Pick auth?",
37
+ prev_state: "active",
38
+ });
39
+ });
40
+
41
+ test("re-emits when the question text changes within needs_input", () => {
42
+ const prev = new Map([[1, agent(1, "needs_input", "Q1")]]);
43
+ const { events } = diffLsStates(prev, [agent(1, "needs_input", "Q2")], 400);
44
+ expect(events).toHaveLength(1);
45
+ expect(events[0]!.question).toBe("Q2");
46
+ });
47
+
48
+ test("synthesizes a stopped event when an agent is reaped between ticks", () => {
49
+ const prev = new Map([[1, agent(1, "active")]]);
50
+ const { events, next } = diffLsStates(prev, [], 500);
51
+ expect(events).toHaveLength(1);
52
+ expect(events[0]).toMatchObject({ pid: 1, state: "stopped", prev_state: "active" });
53
+ expect(next.size).toBe(0);
54
+ });
55
+
56
+ test("does not double-emit stopped for an agent already seen stopped then gone", () => {
57
+ // Already known as stopped, then it drops out of the set → no synthetic event.
58
+ const prev = new Map([[1, agent(1, "stopped")]]);
59
+ const { events } = diffLsStates(prev, [], 600);
60
+ expect(events).toHaveLength(0);
61
+ });
package/ts/lsWatch.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Pure transition-diffing core for `ay ls --watch` — a single NDJSON event
3
+ * stream of agent state changes across ALL (filtered) agents, so a fan-out
4
+ * orchestrator watches one process instead of spawning N per-pid
5
+ * `ay status <pid> --watch`es.
6
+ *
7
+ * The polling/timer lives in `cmdLs`; this module is the pure, synchronous diff
8
+ * between "what I knew last tick" and "what I see now", so it is trivially
9
+ * unit-testable. Keeping it runtime-agnostic and side-effect-free mirrors
10
+ * `needsInput.ts`.
11
+ */
12
+
13
+ export type LiveState = "active" | "idle" | "stopped" | "needs_input";
14
+
15
+ /** The observable state of one agent at a single tick. */
16
+ export interface LsAgentState {
17
+ pid: number;
18
+ cli: string;
19
+ cwd: string;
20
+ state: LiveState;
21
+ /** Pending menu text when state === "needs_input", else null. */
22
+ question: string | null;
23
+ }
24
+
25
+ /** One emitted transition (NDJSON line under `ay ls --watch`). */
26
+ export interface LsWatchEvent {
27
+ ts: number;
28
+ pid: number;
29
+ cli: string;
30
+ cwd: string;
31
+ state: LiveState;
32
+ question: string | null;
33
+ /**
34
+ * The state this agent was last seen in, or null when this is the first time
35
+ * we observe the agent (the baseline emit). Lets a consumer distinguish a
36
+ * genuine transition from the initial snapshot.
37
+ */
38
+ prev_state: LiveState | null;
39
+ }
40
+
41
+ /**
42
+ * Diff the previous per-pid states against the current snapshot and return the
43
+ * transition events to emit plus the next prev-map. Pure: no I/O, no clock —
44
+ * the caller passes `ts`.
45
+ *
46
+ * Emits an event when:
47
+ * - an agent is seen for the first time (baseline, `prev_state: null`)
48
+ * - its `state` or `question` changed since last tick
49
+ * - it vanished from the live set without us ever seeing it `stopped` (reaped
50
+ * between ticks) — a synthetic `stopped` event so a "done" transition is
51
+ * never silently dropped.
52
+ */
53
+ export function diffLsStates(
54
+ prev: Map<number, LsAgentState>,
55
+ cur: LsAgentState[],
56
+ ts: number,
57
+ ): { events: LsWatchEvent[]; next: Map<number, LsAgentState> } {
58
+ const events: LsWatchEvent[] = [];
59
+ const next = new Map<number, LsAgentState>();
60
+ const curPids = new Set<number>();
61
+
62
+ for (const a of cur) {
63
+ curPids.add(a.pid);
64
+ next.set(a.pid, a);
65
+ const p = prev.get(a.pid);
66
+ if (!p) {
67
+ events.push({ ...toEvent(a, ts), prev_state: null });
68
+ } else if (p.state !== a.state || p.question !== a.question) {
69
+ events.push({ ...toEvent(a, ts), prev_state: p.state });
70
+ }
71
+ }
72
+
73
+ // Agents that dropped out of the live set (reaped) before we observed their
74
+ // exit: synthesize a stopped transition so consumers see the agent finish.
75
+ for (const [pid, p] of prev) {
76
+ if (!curPids.has(pid) && p.state !== "stopped") {
77
+ events.push({
78
+ ts,
79
+ pid,
80
+ cli: p.cli,
81
+ cwd: p.cwd,
82
+ state: "stopped",
83
+ question: null,
84
+ prev_state: p.state,
85
+ });
86
+ }
87
+ }
88
+
89
+ return { events, next };
90
+ }
91
+
92
+ function toEvent(a: LsAgentState, ts: number): Omit<LsWatchEvent, "prev_state"> {
93
+ return { ts, pid: a.pid, cli: a.cli, cwd: a.cwd, state: a.state, question: a.question };
94
+ }
@@ -0,0 +1,55 @@
1
+ import { expect, test } from "vitest";
2
+ import { classifyNeedsInput } from "./needsInput.ts";
3
+ import { loadSharedCliDefaults } from "./configShared.ts";
4
+
5
+ // Use the REAL shipped claude/codex patterns so the test guards the actual config.
6
+ const defaults = await loadSharedCliDefaults();
7
+ const claude = { needsInput: defaults.claude?.needsInput, working: defaults.claude?.working };
8
+ const codex = { needsInput: defaults.codex?.needsInput, working: defaults.codex?.working };
9
+
10
+ test("claude config actually ships a needsInput pattern", () => {
11
+ expect(claude.needsInput?.length).toBeGreaterThan(0);
12
+ });
13
+
14
+ test("detects a claude AskUserQuestion selection menu", () => {
15
+ const screen = [
16
+ "Which auth method should we use?",
17
+ "",
18
+ "❯ 1. Session tokens",
19
+ " 2. JWT",
20
+ " 3. OAuth",
21
+ "",
22
+ "? for shortcuts",
23
+ ];
24
+ const ni = classifyNeedsInput(screen, claude);
25
+ expect(ni).not.toBeNull();
26
+ expect(ni!.question).toContain("auth method");
27
+ });
28
+
29
+ test("a plain idle prompt is NOT needs_input", () => {
30
+ const screen = ['❯ Try "fix the bug in auth.ts"', "", "? for shortcuts"];
31
+ expect(classifyNeedsInput(screen, claude)).toBeNull();
32
+ });
33
+
34
+ test("an actively-working agent is NOT needs_input even if a menu lingers above", () => {
35
+ const screen = [
36
+ "❯ 1. Session tokens", // stale menu in scrollback
37
+ "✻ Working… (3s · esc to interrupt)",
38
+ ];
39
+ expect(classifyNeedsInput(screen, claude)).toBeNull();
40
+ });
41
+
42
+ test("regular numbered output (no cursor glyph) is NOT a menu", () => {
43
+ const screen = ["Here are the steps:", "1. do this", "2. then that", "? for shortcuts"];
44
+ expect(classifyNeedsInput(screen, claude)).toBeNull();
45
+ });
46
+
47
+ test("detects a codex selection menu (› cursor)", () => {
48
+ const screen = ["Pick a branch to target", "› 1. main", " 2. develop"];
49
+ const ni = classifyNeedsInput(screen, codex);
50
+ expect(ni).not.toBeNull();
51
+ });
52
+
53
+ test("no patterns configured → always null", () => {
54
+ expect(classifyNeedsInput(["❯ 1. anything"], { needsInput: [], working: [] })).toBeNull();
55
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Detect, from an agent's rendered TUI screen, whether it is blocked on an
3
+ * interactive selection menu it did NOT auto-resolve — i.e. it `needs_input`.
4
+ *
5
+ * This is a QUERY-time classifier (used by `ay ls` / `ay status`), deliberately
6
+ * not part of the run loop: the same drawn menu is observable regardless of which
7
+ * runtime (Rust or TS) produced it, so detection is runtime-agnostic and needs no
8
+ * new IPC or persisted state. The signal is the menu cursor sitting on a numbered
9
+ * option (config `needsInput` patterns, e.g. claude `❯ N.`, codex `›/> N.`). An
10
+ * agent that is actively `working` is never `needs_input`, even if an old menu
11
+ * lingers in the scrollback.
12
+ */
13
+
14
+ export interface NeedsInput {
15
+ /** A compact rendering of the pending question/menu, for `ay status --json`. */
16
+ question: string;
17
+ }
18
+
19
+ // Config regexes are authored without the global/sticky flags, but strip them
20
+ // defensively so `.test()` can't carry `lastIndex` state across calls.
21
+ function reTest(re: RegExp, s: string): boolean {
22
+ return (re.global || re.sticky ? new RegExp(re.source, re.flags.replace(/[gy]/g, "")) : re).test(
23
+ s,
24
+ );
25
+ }
26
+
27
+ function isChromeLine(s: string): boolean {
28
+ const t = s.trim();
29
+ return (
30
+ !t ||
31
+ /^─+$/.test(t) ||
32
+ /^esc to (interrupt|cancel)/i.test(t) ||
33
+ /\? for shortcuts/.test(t) ||
34
+ /\d+%\s*until auto-compact/i.test(t)
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Returns a NeedsInput when the screen shows an unresolved selection menu, else
40
+ * null. `cfg.working` short-circuits to null (an actively-working agent isn't
41
+ * blocked). Pure + synchronous so it's trivially unit-testable.
42
+ */
43
+ export function classifyNeedsInput(
44
+ lines: string[],
45
+ cfg: { needsInput?: RegExp[]; working?: RegExp[] },
46
+ ): NeedsInput | null {
47
+ const patterns = cfg.needsInput ?? [];
48
+ if (patterns.length === 0) return null;
49
+
50
+ const text = lines.join("\n");
51
+ // `working` wins: a spinner means real work is happening, not a blocking prompt.
52
+ if ((cfg.working ?? []).some((re) => reTest(re, text))) return null;
53
+ if (!patterns.some((re) => reTest(re, text))) return null;
54
+
55
+ // Build a compact question from the menu region: the last line carrying the
56
+ // menu cursor, plus a little context above (the question) and the options below.
57
+ let last = -1;
58
+ for (let i = 0; i < lines.length; i++) {
59
+ if (patterns.some((re) => reTest(re, lines[i]!))) last = i;
60
+ }
61
+ const start = Math.max(0, last - 6);
62
+ const end = Math.min(lines.length, last + 6);
63
+ const block = lines
64
+ .slice(start, end)
65
+ .map((l) => l.trim())
66
+ .filter((l) => l && !isChromeLine(l));
67
+ return { question: block.join(" • ").slice(0, 400) };
68
+ }
package/ts/pidStore.ts CHANGED
@@ -55,6 +55,7 @@ export class PidStore {
55
55
  prompt,
56
56
  cwd,
57
57
  wrapperPid,
58
+ parentPid,
58
59
  }: {
59
60
  pid: number;
60
61
  cli: string;
@@ -62,6 +63,7 @@ export class PidStore {
62
63
  prompt?: string;
63
64
  cwd: string;
64
65
  wrapperPid?: number;
66
+ parentPid?: number;
65
67
  }): Promise<PidRecord> {
66
68
  const now = Date.now();
67
69
  const argsJson = JSON.stringify(args);
@@ -116,6 +118,7 @@ export class PidStore {
116
118
  exit_reason: null,
117
119
  started_at: now,
118
120
  wrapper_pid: wrapperPid ?? null,
121
+ parent_pid: parentPid ?? null,
119
122
  })
120
123
  .then(() => maybeCompactGlobalPids())
121
124
  .catch(() => null);
package/ts/reaper.spec.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, expect, test } from "vitest";
2
- import { mkdtempSync, readFileSync } from "fs";
2
+ import { mkdtempSync, readFileSync, writeFileSync } from "fs";
3
3
  import { tmpdir } from "os";
4
4
  import path from "path";
5
- import { register, sweep } from "./reaper.ts";
5
+ import { pgidForWrapper, register, sweep } from "./reaper.ts";
6
6
 
7
7
  let prevHome: string | undefined;
8
8
 
@@ -43,3 +43,27 @@ test("register refuses to persist a pgid <= 1", async () => {
43
43
  await sweep();
44
44
  expect(() => readFileSync(registryFile(), "utf8")).toThrow();
45
45
  });
46
+
47
+ test("pgidForWrapper returns the newest matching pgid, ignoring junk + bad entries", async () => {
48
+ writeFileSync(
49
+ registryFile(),
50
+ [
51
+ JSON.stringify({ wpid: 4242, pgid: 100 }),
52
+ "not-json", // malformed → skipped, not thrown
53
+ "", // blank → skipped
54
+ JSON.stringify({ wpid: 9999, pgid: 200 }), // different wrapper → ignored for 4242
55
+ JSON.stringify({ wpid: 4242, pgid: 1 }), // pgid <= 1 → ignored
56
+ JSON.stringify({ wpid: 4242, pgid: 300 }), // newest valid for 4242 → wins
57
+ ].join("\n") + "\n",
58
+ );
59
+ expect(await pgidForWrapper(4242)).toBe(300);
60
+ expect(await pgidForWrapper(9999)).toBe(200);
61
+ expect(await pgidForWrapper(1234)).toBeNull(); // no entry for this wrapper
62
+ });
63
+
64
+ test("pgidForWrapper returns null for an invalid wpid or a missing registry", async () => {
65
+ expect(await pgidForWrapper(0)).toBeNull(); // !wpid
66
+ expect(await pgidForWrapper(1)).toBeNull(); // wpid <= 1 (never ppid==1)
67
+ // Fresh home, no registry file written → readFile throws → null (not a crash).
68
+ expect(await pgidForWrapper(4242)).toBeNull();
69
+ });
package/ts/reaper.ts CHANGED
@@ -21,6 +21,31 @@ function isAlive(pid: number): boolean {
21
21
  }
22
22
  }
23
23
 
24
+ /** The recorded process-group id for a wrapper pid (newest entry wins), or null
25
+ * if unknown. Lets a force-kill target the agent's whole group, not just its
26
+ * wrapper pid — same registry the orphan sweep uses. */
27
+ export async function pgidForWrapper(wpid: number): Promise<number | null> {
28
+ if (!wpid || wpid <= 1) return null;
29
+ let content: string;
30
+ try {
31
+ content = await readFile(registryPath(), "utf8");
32
+ } catch {
33
+ return null;
34
+ }
35
+ let pgid: number | null = null;
36
+ for (const line of content.split("\n")) {
37
+ const t = line.trim();
38
+ if (!t) continue;
39
+ try {
40
+ const e = JSON.parse(t);
41
+ if (e.wpid === wpid && typeof e.pgid === "number" && e.pgid > 1) pgid = e.pgid;
42
+ } catch {
43
+ // skip malformed
44
+ }
45
+ }
46
+ return pgid;
47
+ }
48
+
24
49
  /** Record this wrapper + its agent's process group for later sweeping. */
25
50
  export async function register(wrapperPid: number, pgid: number): Promise<void> {
26
51
  if (pgid <= 1) return; // never persist a group we'd refuse to signal
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildStoredResult, normalizeEnvelope, resultPath, resultsDir } from "./resultEnvelope.ts";
3
+
4
+ describe("normalizeEnvelope", () => {
5
+ it("passes a JSON object through unchanged", () => {
6
+ const out = normalizeEnvelope('{"status":"done","commits":["abc123"]}');
7
+ expect(out).toEqual({ status: "done", commits: ["abc123"] });
8
+ });
9
+
10
+ it("keeps JSON arrays and scalars as-is (agent owns the shape)", () => {
11
+ expect(normalizeEnvelope("[1,2,3]")).toEqual([1, 2, 3]);
12
+ expect(normalizeEnvelope("42")).toBe(42);
13
+ });
14
+
15
+ it("wraps non-JSON text as a summary instead of rejecting", () => {
16
+ expect(normalizeEnvelope("shipped the fix")).toEqual({ summary: "shipped the fix" });
17
+ });
18
+
19
+ it("trims surrounding whitespace before parsing", () => {
20
+ expect(normalizeEnvelope(' \n {"ok":true}\n ')).toEqual({ ok: true });
21
+ });
22
+
23
+ it("returns null for empty / whitespace-only input", () => {
24
+ expect(normalizeEnvelope("")).toBeNull();
25
+ expect(normalizeEnvelope(" \n\t")).toBeNull();
26
+ });
27
+ });
28
+
29
+ describe("buildStoredResult", () => {
30
+ it("wraps the payload with correlation metadata", () => {
31
+ expect(buildStoredResult(123, { status: "done" }, 1000)).toEqual({
32
+ pid: 123,
33
+ written_at: 1000,
34
+ result: { status: "done" },
35
+ });
36
+ });
37
+ });
38
+
39
+ describe("resultPath", () => {
40
+ it("derives a per-pid path under the results dir", () => {
41
+ expect(resultPath(777)).toBe(`${resultsDir()}/777.json`);
42
+ });
43
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Structured result envelope — P4 of the orchestrator-observability work.
3
+ *
4
+ * A fan-out parent that spawned a sub-agent wants its *outcome* (branch, commit
5
+ * SHAs, changed files, status, blockers, a summary) as machine-readable data,
6
+ * not by grepping `ay tail`. This is the agent-yes analog of an in-harness
7
+ * Agent tool's `<result>` block. The sub-agent deposits one JSON envelope when
8
+ * it finishes; the parent pulls it with `ay result <keyword>`.
9
+ *
10
+ * Why a PERSISTED file, not a query-time screen scrape (the model that
11
+ * `needs_input`/activity use): a completion record is read AFTER the agent is
12
+ * done — exactly when its rendered screen is gone and its log may be reaped. It
13
+ * must outlive the process, so it is written once to
14
+ * `$AGENT_YES_HOME/results/<pid>.json` and read back verbatim. It is keyed by
15
+ * the wrapper pid the agent already knows via the injected `AGENT_YES_PID` env
16
+ * var, so depositing needs no new spawn-time wiring in either runtime.
17
+ *
18
+ * This module is the pure, fs-free core (path math + input normalization) so it
19
+ * is trivially unit-testable, mirroring `lsWatch.ts` / `needsInput.ts`. The fs
20
+ * read/write + CLI live in `subcommands.ts` (`cmdResult`).
21
+ */
22
+
23
+ import path from "path";
24
+ import { agentYesHome } from "./agentYesHome.ts";
25
+
26
+ /**
27
+ * The on-disk shape: agent payload (`result`) plus the minimal metadata a
28
+ * consumer needs to correlate it. `result` is whatever JSON the agent emitted —
29
+ * we don't enforce a schema, only suggest one (see `ResultPayload`).
30
+ */
31
+ export interface StoredResult {
32
+ pid: number;
33
+ written_at: number;
34
+ result: unknown;
35
+ }
36
+
37
+ /**
38
+ * The SUGGESTED envelope an agent emits. None of it is required or validated —
39
+ * it documents the convention an orchestrator can rely on when it controls the
40
+ * sub-agent's prompt. Extra fields pass through untouched.
41
+ */
42
+ export interface ResultPayload {
43
+ /** Operator-facing rollup: "done", "blocked", "failed", or free text. */
44
+ status?: string;
45
+ /** One-line (or short) summary of what was accomplished. */
46
+ summary?: string;
47
+ /** Git branch the work landed on. */
48
+ branch?: string;
49
+ /** Commit SHAs produced, oldest→newest. */
50
+ commits?: string[];
51
+ /** Paths touched (relative to the repo root). */
52
+ files?: string[];
53
+ /** Anything that blocked completion / needs the parent's attention. */
54
+ blockers?: string[];
55
+ [k: string]: unknown;
56
+ }
57
+
58
+ /** Directory holding the per-pid result files. */
59
+ export function resultsDir(): string {
60
+ return path.join(agentYesHome(), "results");
61
+ }
62
+
63
+ /** Absolute path of one agent's result envelope. */
64
+ export function resultPath(pid: number): string {
65
+ return path.join(resultsDir(), `${pid}.json`);
66
+ }
67
+
68
+ /**
69
+ * Coerce raw write-side input into an envelope payload. If it parses as JSON we
70
+ * keep it as-is (object, array, or scalar — the agent owns the shape). If it
71
+ * does NOT parse, we don't reject: a bare string is a perfectly good summary, so
72
+ * we wrap it as `{ summary }`. Empty / whitespace-only input is an error the
73
+ * caller should surface (returns null).
74
+ */
75
+ export function normalizeEnvelope(raw: string): unknown | null {
76
+ const trimmed = raw.trim();
77
+ if (trimmed.length === 0) return null;
78
+ try {
79
+ return JSON.parse(trimmed);
80
+ } catch {
81
+ return { summary: trimmed } satisfies ResultPayload;
82
+ }
83
+ }
84
+
85
+ /** Wrap a normalized payload with correlation metadata for persistence. */
86
+ export function buildStoredResult(pid: number, result: unknown, writtenAt: number): StoredResult {
87
+ return { pid, written_at: writtenAt, result };
88
+ }