agent-lyceum 0.20.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,26 @@ 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");
162
177
  const onHup = handler("Hung up");
163
178
  process.on("SIGINT", onInt);
164
179
  process.on("SIGTERM", onTerm);
165
180
  process.on("SIGHUP", onHup); // the terminal went away
181
+ const stopWatch = startedByWrapper() ? watchParent(onParentGone, { startPpid: LAUNCH_PPID }) : () => { };
166
182
  return { signal: ac.signal, dispose: () => {
183
+ stopWatch();
167
184
  process.off("SIGINT", onInt);
168
185
  process.off("SIGTERM", onTerm);
169
186
  process.off("SIGHUP", onHup);
@@ -777,8 +794,16 @@ program
777
794
  }
778
795
  process.stdout.write(`\x1b[2J\x1b[H${text}\n\nRefreshing every ${sec}s — ${new Date().toLocaleTimeString()} — Ctrl-C to quit\n`);
779
796
  };
780
- draw();
781
- 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
+ });
782
807
  await new Promise(() => { });
783
808
  }
784
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.20.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",