agent-lyceum 0.19.0 → 0.21.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/dist/cli.js CHANGED
@@ -22,6 +22,8 @@ import { prepareTask, readTaskFile, taskMessageBody } from "./task.js";
22
22
  import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
23
23
  import { formatEnforcement, validateProject } from "./validate.js";
24
24
  import { must } from "./assert.js";
25
+ import { runMonitor } from "./monitor-lifecycle.js";
26
+ import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
25
27
  import { onlyAgent, soloProject } from "./solo.js";
26
28
  import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
27
29
  const program = new Command();
@@ -145,6 +147,8 @@ function takeLock(pr, runId, opts = {}) {
145
147
  /**
146
148
  * Ctrl-C (or SIGTERM) stops the run cleanly: the first signal cancels it (agents are killed, unread mail is kept,
147
149
  * the lock is released, exit code 130); a second one quits at once.
150
+ * Started through a launcher that does not relay SIGHUP (npx, npm exec, tsx), the launcher exiting counts as that
151
+ * first signal: otherwise closing the terminal would leave this process running on its own (spec 21).
148
152
  */
149
153
  function cancelOnSignals() {
150
154
  const ac = new AbortController();
@@ -157,13 +161,29 @@ function cancelOnSignals() {
157
161
  else
158
162
  process.exit(130);
159
163
  };
164
+ const onParentGone = () => {
165
+ if (seen > 0)
166
+ return; // already stopping
167
+ seen = 1;
168
+ // stderr may be a dead terminal or pipe: console.error would throw EPIPE/EIO there
169
+ try {
170
+ fs.writeSync(2, "\nLauncher exited (parent process gone): stopping the run (running agents are being stopped; unread mail is kept).\n");
171
+ }
172
+ catch { }
173
+ ac.abort();
174
+ };
160
175
  const onInt = handler("Interrupted");
161
176
  const onTerm = handler("Terminated");
177
+ const onHup = handler("Hung up");
162
178
  process.on("SIGINT", onInt);
163
179
  process.on("SIGTERM", onTerm);
180
+ process.on("SIGHUP", onHup); // the terminal went away
181
+ const stopWatch = startedByWrapper() ? watchParent(onParentGone, { startPpid: LAUNCH_PPID }) : () => { };
164
182
  return { signal: ac.signal, dispose: () => {
183
+ stopWatch();
165
184
  process.off("SIGINT", onInt);
166
185
  process.off("SIGTERM", onTerm);
186
+ process.off("SIGHUP", onHup);
167
187
  },
168
188
  };
169
189
  }
@@ -774,8 +794,16 @@ program
774
794
  }
775
795
  process.stdout.write(`\x1b[2J\x1b[H${text}\n\nRefreshing every ${sec}s — ${new Date().toLocaleTimeString()} — Ctrl-C to quit\n`);
776
796
  };
777
- draw();
778
- setInterval(draw, sec * 1000);
797
+ runMonitor({
798
+ draw,
799
+ intervalMs: sec * 1000,
800
+ stdout: process.stdout,
801
+ stdin: process.stdin,
802
+ ppid: () => process.ppid,
803
+ exit: (code) => process.exit(code),
804
+ onSignal: (sig, handler) => process.on(sig, handler),
805
+ fail: (message) => fail(message),
806
+ });
779
807
  await new Promise(() => { });
780
808
  }
781
809
  catch (e) {
@@ -0,0 +1,19 @@
1
+ import type { EventEmitter } from "node:events";
2
+ export interface MonitorDeps {
3
+ draw: () => void;
4
+ intervalMs: number;
5
+ stdout: EventEmitter & {
6
+ destroyed?: boolean;
7
+ writable?: boolean;
8
+ };
9
+ stdin: EventEmitter & {
10
+ isTTY?: boolean;
11
+ resume: () => unknown;
12
+ };
13
+ ppid: () => number;
14
+ exit: (code: number) => void;
15
+ onSignal: (sig: NodeJS.Signals, handler: () => void) => void;
16
+ fail: (message: string) => void;
17
+ }
18
+ /** Run `draw` every interval and end the process when the monitor is closed: signal, dead stdout, TTY stdin close, or lost parent. */
19
+ export declare function runMonitor(d: MonitorDeps): void;
@@ -0,0 +1,42 @@
1
+ const SIGNAL_CODES = [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]];
2
+ /** Run `draw` every interval and end the process when the monitor is closed: signal, dead stdout, TTY stdin close, or lost parent. */
3
+ export function runMonitor(d) {
4
+ const startPpid = d.ppid();
5
+ let timer;
6
+ let done = false;
7
+ const stop = (code) => {
8
+ if (done)
9
+ return;
10
+ done = true;
11
+ if (timer)
12
+ clearInterval(timer);
13
+ d.exit(code);
14
+ };
15
+ for (const [sig, code] of SIGNAL_CODES)
16
+ d.onSignal(sig, () => stop(code));
17
+ d.stdout.on("error", (e) => {
18
+ if (e.code === "EPIPE" || e.code === "EIO")
19
+ return stop(0);
20
+ done = true;
21
+ if (timer)
22
+ clearInterval(timer);
23
+ d.fail(e.message);
24
+ });
25
+ if (d.stdin.isTTY) {
26
+ for (const ev of ["end", "close", "error"])
27
+ d.stdin.on(ev, () => stop(0));
28
+ d.stdin.resume(); // a TTY only reports EOF while it is being read
29
+ }
30
+ const tick = () => {
31
+ if (done)
32
+ return;
33
+ if (d.ppid() !== startPpid)
34
+ return stop(129);
35
+ if (d.stdout.destroyed || d.stdout.writable === false)
36
+ return stop(0);
37
+ d.draw();
38
+ };
39
+ tick();
40
+ if (!done)
41
+ timer = setInterval(tick, d.intervalMs);
42
+ }
@@ -0,0 +1,17 @@
1
+ /** The parent pid when the process started (read at module load, before config loading and preflight). */
2
+ export declare const LAUNCH_PPID: number;
3
+ /**
4
+ * Started by a launcher that does not relay SIGHUP: npm/npx/pnpm/yarn (they set `npm_execpath`), or the tsx CLI
5
+ * (its child runs with `--require …/tsx/dist/preflight.cjs`). A directly run binary is never watched, so runs that are
6
+ * detached on purpose (`&`, `disown`, `setsid`, supervisors) keep running when their parent exits.
7
+ */
8
+ export declare function startedByWrapper(env?: NodeJS.ProcessEnv, execArgv?: readonly string[]): boolean;
9
+ /**
10
+ * Call `onGone` once when the parent pid differs from `startPpid` (the launcher exited and the process was adopted):
11
+ * checked at once, then every `intervalMs` on an unref'd timer. Returns a function that stops the watch.
12
+ */
13
+ export declare function watchParent(onGone: () => void, opts: {
14
+ startPpid: number;
15
+ ppid?: () => number;
16
+ intervalMs?: number;
17
+ }): () => void;
@@ -0,0 +1,37 @@
1
+ /** The parent pid when the process started (read at module load, before config loading and preflight). */
2
+ export const LAUNCH_PPID = process.ppid;
3
+ /**
4
+ * Started by a launcher that does not relay SIGHUP: npm/npx/pnpm/yarn (they set `npm_execpath`), or the tsx CLI
5
+ * (its child runs with `--require …/tsx/dist/preflight.cjs`). A directly run binary is never watched, so runs that are
6
+ * detached on purpose (`&`, `disown`, `setsid`, supervisors) keep running when their parent exits.
7
+ */
8
+ export function startedByWrapper(env = process.env, execArgv = process.execArgv) {
9
+ if (env.npm_execpath)
10
+ return true;
11
+ return execArgv.some((a) => /[\\/]tsx[\\/]dist[\\/]preflight\.cjs$/.test(a));
12
+ }
13
+ /**
14
+ * Call `onGone` once when the parent pid differs from `startPpid` (the launcher exited and the process was adopted):
15
+ * checked at once, then every `intervalMs` on an unref'd timer. Returns a function that stops the watch.
16
+ */
17
+ export function watchParent(onGone, opts) {
18
+ const ppid = opts.ppid ?? (() => process.ppid);
19
+ let timer;
20
+ const stop = () => {
21
+ if (timer)
22
+ clearInterval(timer);
23
+ timer = undefined;
24
+ };
25
+ if (ppid() !== opts.startPpid) {
26
+ onGone();
27
+ return stop;
28
+ }
29
+ timer = setInterval(() => {
30
+ if (ppid() === opts.startPpid)
31
+ return;
32
+ stop();
33
+ onGone();
34
+ }, opts.intervalMs ?? 1000);
35
+ timer.unref();
36
+ return stop;
37
+ }
@@ -13,6 +13,16 @@ export interface RunnerOptions {
13
13
  /** How long to wait for output pipes to close after the process is gone or killed, before giving up on them. Default 1000. */
14
14
  settleWaitMs?: number;
15
15
  }
16
+ /** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
17
+ export declare function descendantsOf(root: number): number[];
18
+ /** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
19
+ export declare function setSamplerIntervalMs(ms: number): void;
20
+ /** Whether the descendant timer is running (for tests). */
21
+ export declare function samplerActive(): boolean;
22
+ /** How many runtimes are registered as running (for tests). */
23
+ export declare function runningCount(): number;
24
+ /** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
25
+ export declare function killAllRunning(): void;
16
26
  /**
17
27
  * Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
18
28
  * and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  /** Most output kept in memory per stream; the rest only goes to the log files. */
@@ -31,6 +31,121 @@ function endStream(s) {
31
31
  });
32
32
  });
33
33
  }
34
+ /**
35
+ * Runtimes that are running right now, with the descendants seen so far, so an exiting agent-lyceum can take them with it.
36
+ * A descendant that left the process group (setsid, daemon) is only found by parent links while its parent is alive, so a
37
+ * shared timer samples the process table while any runtime is registered. Remaining gap: a child that escapes and
38
+ * outlives its parent within one sampling interval is never seen.
39
+ */
40
+ const live = new Map();
41
+ let exitHook = false;
42
+ let sampler;
43
+ let samplerMs = 2000;
44
+ /** Parent -> children from one `ps` run, or undefined when it fails or hangs (SIGKILL on timeout: a ps that ignores SIGTERM must not block, even in the exit hook). */
45
+ function processTable() {
46
+ if (process.platform === "win32")
47
+ return undefined;
48
+ const ps = spawnSync("ps", ["-A", "-o", "pid=,ppid="], { encoding: "utf8", timeout: 2000, killSignal: "SIGKILL" });
49
+ if (ps.status !== 0 || !ps.stdout)
50
+ return undefined;
51
+ const kids = new Map();
52
+ for (const line of ps.stdout.split("\n")) {
53
+ const [pid, ppid] = line.trim().split(/\s+/).map(Number);
54
+ if (!pid || !ppid)
55
+ continue;
56
+ kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
57
+ }
58
+ return kids;
59
+ }
60
+ function below(kids, root) {
61
+ const out = [];
62
+ if (!kids)
63
+ return out;
64
+ for (let todo = [root]; todo.length;) {
65
+ const next = [];
66
+ for (const p of todo) {
67
+ for (const k of kids.get(p) ?? []) {
68
+ if (out.includes(k))
69
+ continue;
70
+ out.push(k);
71
+ next.push(k);
72
+ }
73
+ }
74
+ todo = next;
75
+ }
76
+ return out;
77
+ }
78
+ /** Pids below `root` (children, grandchildren, ...) by parent links; catches descendants that left the process group (setsid, daemons). */
79
+ export function descendantsOf(root) {
80
+ return below(processTable(), root);
81
+ }
82
+ /** Record the current descendants of every live runtime (one `ps` for all). */
83
+ function sample() {
84
+ if (!live.size)
85
+ return;
86
+ const kids = processTable();
87
+ for (const [pid, seen] of live)
88
+ for (const d of below(kids, pid))
89
+ seen.add(d);
90
+ }
91
+ function register(pid) {
92
+ live.set(pid, new Set());
93
+ if (!exitHook) {
94
+ exitHook = true;
95
+ process.on("exit", killAllRunning); // process.exit() and crashes must not leave detached runtimes behind
96
+ }
97
+ if (!sampler) {
98
+ sampler = setInterval(sample, samplerMs);
99
+ sampler.unref();
100
+ }
101
+ }
102
+ function unregister(pid) {
103
+ live.delete(pid);
104
+ if (!live.size && sampler) {
105
+ clearInterval(sampler);
106
+ sampler = undefined;
107
+ }
108
+ }
109
+ /** Sampling interval of the descendant timer (default 2000 ms); for tests. Takes effect for the next timer. */
110
+ export function setSamplerIntervalMs(ms) {
111
+ samplerMs = ms;
112
+ }
113
+ /** Whether the descendant timer is running (for tests). */
114
+ export function samplerActive() {
115
+ return sampler !== undefined;
116
+ }
117
+ function signalPid(pid, sig, group) {
118
+ try {
119
+ process.kill(group && process.platform !== "win32" ? -pid : pid, sig);
120
+ }
121
+ catch {
122
+ /* already gone */
123
+ }
124
+ }
125
+ /** Signal the runtime's group, the descendants recorded earlier that are still in the process table, and its current descendants. */
126
+ function killTree(pid, sig) {
127
+ const kids = processTable();
128
+ const seen = live.get(pid) ?? new Set();
129
+ const now = below(kids, pid);
130
+ const present = kids ? new Set([...kids.values()].flat()) : undefined; // without a table, trust the record
131
+ for (const d of now)
132
+ seen.add(d);
133
+ signalPid(pid, sig, true);
134
+ for (const d of seen)
135
+ if (!present || present.has(d))
136
+ signalPid(d, sig, false);
137
+ }
138
+ /** How many runtimes are registered as running (for tests). */
139
+ export function runningCount() {
140
+ return live.size;
141
+ }
142
+ /** Synchronously SIGKILL every runtime still running, with the descendants seen when it was started or last checked. Used when agent-lyceum itself is going away. */
143
+ export function killAllRunning() {
144
+ for (const pid of [...live.keys()]) {
145
+ killTree(pid, "SIGKILL");
146
+ unregister(pid);
147
+ }
148
+ }
34
149
  /**
35
150
  * Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
36
151
  * and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
@@ -49,6 +164,8 @@ export function runInvocation(inv, opts) {
49
164
  outLog.on("error", () => { });
50
165
  errLog.on("error", () => { });
51
166
  const child = spawn(inv.cmd, inv.args, { cwd: inv.cwd, env: inv.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
167
+ if (child.pid !== undefined)
168
+ register(child.pid);
52
169
  const stdoutTail = new Tail();
53
170
  const stderrTail = new Tail();
54
171
  const parser = inv.stream?.();
@@ -58,17 +175,8 @@ export function runInvocation(inv, opts) {
58
175
  let settled = false;
59
176
  const timers = [];
60
177
  const killGroup = (sig) => {
61
- if (child.pid === undefined)
62
- return;
63
- try {
64
- if (process.platform === "win32")
65
- child.kill(sig);
66
- else
67
- process.kill(-child.pid, sig);
68
- }
69
- catch {
70
- /* nothing left to signal */
71
- }
178
+ if (child.pid !== undefined)
179
+ killTree(child.pid, sig);
72
180
  };
73
181
  const finish = (spawnError) => {
74
182
  if (settled)
@@ -78,6 +186,8 @@ export function runInvocation(inv, opts) {
78
186
  clearTimeout(t);
79
187
  opts.signal?.removeEventListener("abort", onAbort);
80
188
  killGroup("SIGKILL"); // whatever of the tree is still around
189
+ if (child.pid !== undefined)
190
+ unregister(child.pid);
81
191
  child.stdout?.destroy();
82
192
  child.stderr?.destroy();
83
193
  void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",