agent-lyceum 0.19.0 → 0.20.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
@@ -159,11 +159,14 @@ function cancelOnSignals() {
159
159
  };
160
160
  const onInt = handler("Interrupted");
161
161
  const onTerm = handler("Terminated");
162
+ const onHup = handler("Hung up");
162
163
  process.on("SIGINT", onInt);
163
164
  process.on("SIGTERM", onTerm);
165
+ process.on("SIGHUP", onHup); // the terminal went away
164
166
  return { signal: ac.signal, dispose: () => {
165
167
  process.off("SIGINT", onInt);
166
168
  process.off("SIGTERM", onTerm);
169
+ process.off("SIGHUP", onHup);
167
170
  },
168
171
  };
169
172
  }
@@ -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.20.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",