agent-lyceum 0.29.0 → 0.30.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
@@ -24,6 +24,7 @@ import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
24
24
  import { formatEnforcement, validateProject } from "./validate.js";
25
25
  import { must } from "./assert.js";
26
26
  import { runMonitor } from "./monitor-lifecycle.js";
27
+ import { codeFingerprint, watchCodeChange, WATCH_RESTART_EXIT, WATCH_RESTART_MESSAGE } from "./code-change.js";
27
28
  import { createStatusWatcher } from "./status-watch.js";
28
29
  import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
29
30
  import { onlyAgent, soloProject } from "./solo.js";
@@ -832,8 +833,22 @@ program
832
833
  write: (line) => process.stdout.write(`${line}\n`),
833
834
  debounceMs: 100,
834
835
  });
836
+ const codeFile = fileURLToPath(import.meta.url);
837
+ const changed = watchCodeChange(() => codeFingerprint(path.dirname(codeFile), path.extname(codeFile)));
835
838
  runMonitor({
836
- draw: watcher.beat,
839
+ draw: () => {
840
+ if (changed()) {
841
+ try {
842
+ fs.writeSync(2, `${WATCH_RESTART_MESSAGE}\n`);
843
+ }
844
+ catch {
845
+ // A closed stderr must not prevent replacement.
846
+ }
847
+ watcher.close();
848
+ process.exit(WATCH_RESTART_EXIT);
849
+ }
850
+ watcher.beat();
851
+ },
837
852
  intervalMs: beat * 1000,
838
853
  stdout: process.stdout,
839
854
  stdin: process.stdin,
@@ -0,0 +1,6 @@
1
+ export declare const WATCH_RESTART_EXIT = 75;
2
+ export declare const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
3
+ /** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
4
+ export declare function codeFingerprint(dir: string, ext: string): string;
5
+ /** Two equal reads debounce changes; they do not establish build completion. */
6
+ export declare function watchCodeChange(read: () => string): () => boolean;
@@ -0,0 +1,37 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export const WATCH_RESTART_EXIT = 75;
4
+ export const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
5
+ /** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
6
+ export function codeFingerprint(dir, ext) {
7
+ const files = [];
8
+ function visit(relative) {
9
+ for (const entry of fs.readdirSync(path.join(dir, relative), { withFileTypes: true })) {
10
+ const rel = path.join(relative, entry.name);
11
+ if (entry.isDirectory())
12
+ visit(rel);
13
+ else if (entry.isFile() && path.extname(entry.name) === ext) {
14
+ const stat = fs.statSync(path.join(dir, rel));
15
+ files.push(`${rel}:${stat.size}:${stat.mtimeMs}`);
16
+ }
17
+ }
18
+ }
19
+ try {
20
+ visit("");
21
+ return files.sort().join("\n");
22
+ }
23
+ catch {
24
+ return "";
25
+ }
26
+ }
27
+ /** Two equal reads debounce changes; they do not establish build completion. */
28
+ export function watchCodeChange(read) {
29
+ const initial = read();
30
+ let previous = initial;
31
+ return () => {
32
+ const current = read();
33
+ const changed = current !== "" && current !== initial && current === previous;
34
+ previous = current;
35
+ return changed;
36
+ };
37
+ }
@@ -44,13 +44,27 @@ export interface PidStartIo {
44
44
  }
45
45
  /** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
46
46
  export declare function pidStartFrom(pid: number, io: PidStartIo): string | undefined;
47
+ /** Reader side: C locale, so `ps` start times stay in one language and `samePidStart` can compare them. */
48
+ export declare const readerPs: (pid: number) => string | undefined;
49
+ /** Writer side: the inherited environment, exactly what versions before 0.29.0 wrote and compare against raw; never pin it, or an older reader in a non-C locale judges the lock dead and takes the run over. */
50
+ export declare const writerPs: (pid: number) => string | undefined;
47
51
  /** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
48
52
  export declare function samePidStart(a: string, b: string): boolean;
53
+ export interface LivenessIo extends PidStartIo {
54
+ signal0(pid: number): boolean;
55
+ psUtc(pid: number): string | undefined;
56
+ }
57
+ export declare const utcPsEnv: (base: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
58
+ export declare const readerPsUtc: (pid: number) => string | undefined;
59
+ /** Only ESRCH proves absence; permissions and unknown signal errors are conservative. */
60
+ export declare function signalAlive(pid: number, kill?: (pid: number, signal: 0) => unknown): boolean;
61
+ /** Conventional quarter-hour offsets only; arbitrary POSIX TZ offsets remain a limitation. */
62
+ export declare function sameStartAnyZone(written: string, utc: string): boolean | undefined;
49
63
  /**
50
64
  * Is the process that wrote this lock still the same running process? Judged by pid and its start time,
51
65
  * never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
52
66
  */
53
- export declare function lockHolderAlive(info: LockInfo): boolean;
67
+ export declare function lockHolderAlive(info: Pick<LockInfo, "pid" | "pid_started">, io?: LivenessIo): boolean;
54
68
  /** Every lock of a project that can be read: one per run, plus an older version's `lock.json`. Oldest first. */
55
69
  export declare function listRunLocks(root: string): LockInfo[];
56
70
  /** Run ids whose per-run lock file exists but cannot be read (it can only be removed with `forceUnlock(root, runId)`). */
@@ -39,28 +39,27 @@ export function pidStartFrom(pid, io) {
39
39
  }
40
40
  return io.ps(pid);
41
41
  }
42
- const realIo = {
43
- platform: process.platform,
44
- readFile: (p) => {
45
- try {
46
- return fs.readFileSync(p, "utf8");
47
- }
48
- catch {
49
- return undefined;
50
- }
51
- },
52
- ps: (pid) => {
53
- // C locale: `ps` start times stay in one language. A lock written from a Chinese terminal would otherwise look like a different process.
54
- const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LANG: "C", LC_ALL: "C" } });
55
- const out = r.status === 0 ? r.stdout.trim() : "";
56
- return out || undefined;
57
- },
42
+ const psLstart = (pid, env) => {
43
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env });
44
+ return (r.status === 0 ? r.stdout.trim() : "") || undefined;
45
+ };
46
+ /** Reader side: C locale, so `ps` start times stay in one language and `samePidStart` can compare them. */
47
+ export const readerPs = (pid) => psLstart(pid, { ...process.env, LANG: "C", LC_ALL: "C" });
48
+ /** Writer side: the inherited environment, exactly what versions before 0.29.0 wrote and compare against raw; never pin it, or an older reader in a non-C locale judges the lock dead and takes the run over. */
49
+ export const writerPs = (pid) => psLstart(pid, process.env);
50
+ const fileReader = (p) => {
51
+ try {
52
+ return fs.readFileSync(p, "utf8");
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
58
57
  };
58
+ const realIo = { platform: process.platform, readFile: fileReader, ps: readerPs };
59
+ const writerIo = { ...realIo, ps: writerPs };
59
60
  function pidStart(pid) {
60
- return pidStartFrom(pid, realIo);
61
+ return pidStartFrom(pid, writerIo);
61
62
  }
62
- /** Two start times can only be compared when they were taken the same way. */
63
- const sameKind = (a, b) => a.startsWith("proc:") === b.startsWith("proc:");
64
63
  const MONTHS = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" };
65
64
  /** `ps -o lstart` in the C locale, or the same instant from a Chinese macOS locale, as `YYYY-MM-DDTHH:mm:ss`. */
66
65
  function canonicalLstart(text) {
@@ -85,24 +84,62 @@ export function samePidStart(a, b) {
85
84
  const right = canonicalLstart(b);
86
85
  return !!left && left === right;
87
86
  }
88
- /**
89
- * Is the process that wrote this lock still the same running process? Judged by pid and its start time,
90
- * never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
91
- */
92
- export function lockHolderAlive(info) {
87
+ export const utcPsEnv = (base) => ({ ...base, LANG: "C", LC_ALL: "C", TZ: "UTC0" });
88
+ export const readerPsUtc = (pid) => psLstart(pid, utcPsEnv(process.env));
89
+ /** Only ESRCH proves absence; permissions and unknown signal errors are conservative. */
90
+ export function signalAlive(pid, kill = process.kill) {
93
91
  try {
94
- process.kill(info.pid, 0);
92
+ kill(pid, 0);
93
+ return true;
95
94
  }
96
95
  catch (e) {
97
- if (e.code !== "EPERM")
98
- return false;
96
+ return e?.code !== "ESRCH";
99
97
  }
100
- if (info.pid_started) {
101
- const now = pidStart(info.pid);
102
- if (now && sameKind(now, info.pid_started) && !samePidStart(info.pid_started, now))
103
- return false;
98
+ }
99
+ const realLivenessIo = {
100
+ ...realIo,
101
+ signal0: signalAlive,
102
+ psUtc: readerPsUtc,
103
+ };
104
+ function lstartFieldsMs(text) {
105
+ const c = canonicalLstart(text);
106
+ const ms = c === undefined ? NaN : Date.parse(`${c}Z`);
107
+ return Number.isFinite(ms) && new Date(ms).toISOString().slice(0, 19) === c ? ms : undefined;
108
+ }
109
+ /** Conventional quarter-hour offsets only; arbitrary POSIX TZ offsets remain a limitation. */
110
+ export function sameStartAnyZone(written, utc) {
111
+ const a = lstartFieldsMs(written);
112
+ const b = lstartFieldsMs(utc);
113
+ if (a === undefined || b === undefined)
114
+ return undefined;
115
+ const d = (a - b) / 1000;
116
+ return Number.isInteger(d) && d % 900 === 0 && Math.abs(d) <= 26 * 3600;
117
+ }
118
+ /**
119
+ * Is the process that wrote this lock still the same running process? Judged by pid and its start time,
120
+ * never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
121
+ */
122
+ export function lockHolderAlive(info, io = realLivenessIo) {
123
+ if (!io.signal0(info.pid))
124
+ return false;
125
+ const written = info.pid_started;
126
+ if (typeof written !== "string" || !written)
127
+ return true;
128
+ if (io.platform === "linux") {
129
+ if (!written.startsWith("proc:"))
130
+ return true;
131
+ const ticks = parseProcStat(io.readFile(`/proc/${info.pid}/stat`) ?? "");
132
+ return !ticks || `proc:${ticks}` === written;
104
133
  }
105
- return true;
134
+ if (written.startsWith("proc:"))
135
+ return true;
136
+ const local = io.ps(info.pid);
137
+ if (!local || samePidStart(written, local))
138
+ return true;
139
+ const utc = io.psUtc(info.pid);
140
+ if (!utc)
141
+ return true;
142
+ return sameStartAnyZone(written, utc) !== false;
106
143
  }
107
144
  function readLockFile(file) {
108
145
  let text;
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import { type StatusReport } from "./status.js";
3
- /** What `status --json --watch` prints, one JSON object per line: the project's report plus one per task that is not completed. */
3
+ /** What `status --json --watch` prints, one JSON object per line: the project's report plus one per task that is not finished (completed or partial). */
4
4
  export type WatchFrame = {
5
5
  schema_version: 1;
6
6
  report?: StatusReport;
@@ -8,9 +8,9 @@ export type WatchFrame = {
8
8
  error?: string;
9
9
  };
10
10
  /**
11
- * The frame for now. Every task that is not completed (running, interrupted, waiting for answers, or ended partial,
12
- * blocked, failed or cancelled) gets its own report, so it stays listed until it is resumed to completion or cleared.
13
- * A task that was running in the previous frame and has completed since is included once more, so the reader sees
11
+ * The frame for now. Every task that is not finished (running, interrupted, waiting for answers, or ended blocked,
12
+ * failed or cancelled) gets its own report, so it stays listed until it is resumed or cleared.
13
+ * A task that was running in the previous frame and has finished (completed or partial) since is included once more, so the reader sees
14
14
  * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
15
15
  */
16
16
  export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>, opts?: {
@@ -1,17 +1,17 @@
1
1
  import fs from "node:fs";
2
- import { buildStatusReport, buildTaskListReport, isCompletedRun } from "./status.js";
2
+ import { buildStatusReport, buildTaskListReport, isFinishedRun } from "./status.js";
3
3
  /**
4
- * The frame for now. Every task that is not completed (running, interrupted, waiting for answers, or ended partial,
5
- * blocked, failed or cancelled) gets its own report, so it stays listed until it is resumed to completion or cleared.
6
- * A task that was running in the previous frame and has completed since is included once more, so the reader sees
4
+ * The frame for now. Every task that is not finished (running, interrupted, waiting for answers, or ended blocked,
5
+ * failed or cancelled) gets its own report, so it stays listed until it is resumed or cleared.
6
+ * A task that was running in the previous frame and has finished (completed or partial) since is included once more, so the reader sees
7
7
  * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
8
8
  */
9
9
  export function buildWatchFrame(project, tracked, opts) {
10
10
  const all = buildTaskListReport(project).runs;
11
11
  const running = all.filter((r) => r.state === "running").map((r) => r.run_id);
12
- const open = all.filter((r) => !isCompletedRun(r)).map((r) => r.run_id);
12
+ const open = all.filter((r) => !isFinishedRun(r)).map((r) => r.run_id);
13
13
  const runs = {};
14
- // The web page keeps every task. The terminal watch drops one after it has completed, once the finish has been seen.
14
+ // The web page keeps every task. The terminal watch drops one after it has finished, once the finish has been seen.
15
15
  const ids = opts?.allTasks ? all.map((r) => r.run_id) : [...open, ...tracked];
16
16
  for (const id of new Set(ids))
17
17
  runs[id] = buildStatusReport(project, id);
package/dist/status.d.ts CHANGED
@@ -173,8 +173,8 @@ export declare function buildRunReport(run: {
173
173
  }, project?: ResolvedProject, opts?: {
174
174
  detail?: boolean;
175
175
  }): RunReport;
176
- /** A run that ended with a verified-or-inferred `completed` outcome: the only kind the monitor stops listing. */
177
- export declare const isCompletedRun: (r: Pick<RunReport, "state" | "outcome">) => boolean;
176
+ /** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
177
+ export declare const isFinishedRun: (r: Pick<RunReport, "state" | "outcome">) => boolean;
178
178
  /** The status of a project: its agents and one run (the newest running one, else the newest, or the one named by `runId`). */
179
179
  export declare function buildStatusReport(base: ResolvedProject, runId?: string, now?: number): StatusReport;
180
180
  /** Every run of the project, newest first. */
package/dist/status.js CHANGED
@@ -266,8 +266,8 @@ export function buildRunReport(run, project, opts = {}) {
266
266
  ...(ask ? { ask } : {}),
267
267
  };
268
268
  }
269
- /** A run that ended with a verified-or-inferred `completed` outcome: the only kind the monitor stops listing. */
270
- export const isCompletedRun = (r) => r.state === "ended" && r.outcome === "completed";
269
+ /** A run that ended with a `completed` or `partial` outcome: the only kinds the monitor stops listing (blocked, failed and cancelled stay). */
270
+ export const isFinishedRun = (r) => r.state === "ended" && (r.outcome === "completed" || r.outcome === "partial");
271
271
  const projectInfo = (p) => ({ name: p.name, dir: p.dir, lead: p.lead });
272
272
  /** The fallback part of an agent's report: nothing at all for an agent without a chain. */
273
273
  function fallbackView(a, state, now) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.29.0",
3
+ "version": "0.30.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",