agent-lyceum 0.34.0 → 0.36.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/README.md CHANGED
@@ -49,7 +49,7 @@ Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyce
49
49
  | `init` | Create the home and the global agent library. [details](docs/commands.md#init) |
50
50
  | `project add` / `project` / `project remove` | Register, list or remove projects. [details](docs/commands.md#project-add) |
51
51
  | `doctor` | Check config, git, lock and runtime CLIs without running agents. [details](docs/commands.md#doctor) |
52
- | `status` | Show agents, mail, the current wake-up and past runs. `status --web` opens every registered project in a browser. [details](docs/commands.md#status) |
52
+ | `status` | Show agents, mail, the current wake-up and past runs. `status --web` opens every registered project in a browser (**breaking:** it now rejects unknown `Host` names; use `--allow-host`). [details](docs/commands.md#status) |
53
53
  | `run` | Give the task to the lead and run until done. [details](docs/commands.md#run) |
54
54
  | `resume` | Continue an interrupted run, or reopen a finished one with a follow-up. [details](docs/commands.md#resume) |
55
55
  | `clear` | Delete a run, or remove a stale lock. [details](docs/commands.md#clear) |
package/README.zh-TW.md CHANGED
@@ -49,7 +49,7 @@ npx agent-lyceum status
49
49
  | `init` | 建立 home 與全域 agent 庫。 [詳細](docs/commands.zh-TW.md#init) |
50
50
  | `project add` / `project` / `project remove` | 註冊、列出或移除專案。 [詳細](docs/commands.zh-TW.md#project-add) |
51
51
  | `doctor` | 不執行 agent,檢查設定、git、鎖與 runtime CLI。 [詳細](docs/commands.zh-TW.md#doctor) |
52
- | `status` | 顯示 agent、信件、目前的喚醒與過去的 run。`status --web` 在瀏覽器看全部已註冊專案。 [詳細](docs/commands.zh-TW.md#status) |
52
+ | `status` | 顯示 agent、信件、目前的喚醒與過去的 run。`status --web` 在瀏覽器看全部已註冊專案(**breaking:** 現在會拒絕未知的 `Host` 名稱,請用 `--allow-host`)。 [詳細](docs/commands.zh-TW.md#status) |
53
53
  | `run` | 把任務交給 lead,執行到完成。 [詳細](docs/commands.zh-TW.md#run) |
54
54
  | `resume` | 繼續中斷的 run,或用追加需求重開已完成的 run。 [詳細](docs/commands.zh-TW.md#resume) |
55
55
  | `clear` | 刪除 run,或移除殘留的鎖。 [詳細](docs/commands.zh-TW.md#clear) |
package/dist/cli.js CHANGED
@@ -25,6 +25,7 @@ import { formatEnforcement, validateProject } from "./validate.js";
25
25
  import { must } from "./assert.js";
26
26
  import { runMonitor } from "./monitor-lifecycle.js";
27
27
  import { codeFingerprint, watchCodeChange, WATCH_RESTART_EXIT, WATCH_RESTART_MESSAGE } from "./code-change.js";
28
+ import { probeHealth, runAttachedWeb } from "./web-attach.js";
28
29
  import { createStatusWatcher } from "./status-watch.js";
29
30
  import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
30
31
  import { onlyAgent, soloProject } from "./solo.js";
@@ -749,16 +750,20 @@ program
749
750
  .option("--port <n>", "with --web: TCP port", "8787")
750
751
  .option("--host <addr>", "with --web: bind address", "127.0.0.1")
751
752
  .option("--no-open", "with --web: do not open a browser")
753
+ .option("--exit-with-parent", "with --web: stop when the launching process goes away (parent exit, closed stdout) and print the URL as the first stdout line")
754
+ .option("--allow-host <name>", "with --web on a loopback address: also accept this Host name (repeatable)", (value, previous) => [...previous, value], [])
752
755
  .action(async (opts) => {
753
756
  try {
757
+ if (opts.exitWithParent && !opts.web)
758
+ fail("--exit-with-parent needs --web.");
754
759
  if (opts.web) {
755
760
  if (opts.json || opts.watch || opts.monitor || opts.taskList || opts.taskId !== undefined)
756
761
  fail("--web cannot be combined with --json, --watch, --monitor, --task-list or --task-id.");
757
762
  if (opts.project)
758
763
  fail("--web shows every registered project; it does not take --project.");
759
764
  const port = Number(opts.port);
760
- if (!Number.isInteger(port) || port < 1 || port > 65535)
761
- fail("--port must be a TCP port.");
765
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
766
+ fail("--port must be a TCP port (0 picks a free one).");
762
767
  const beat = Number(opts.heartbeat);
763
768
  if (!(beat > 0))
764
769
  fail("--heartbeat must be a positive number of seconds.");
@@ -769,12 +774,15 @@ program
769
774
  fail(`The web monitor is not built.\nFrom the repo: pnpm --dir web install && pnpm --dir web build\nExpected ${entry}`);
770
775
  }
771
776
  const mod = (await import(pathToFileURL(entry).href));
772
- const page = await mod.startWebServer({
777
+ const version = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")).version ?? "unknown";
778
+ const startOptions = {
773
779
  port,
774
780
  host: opts.host,
775
781
  open: opts.open !== false,
776
782
  staticDir,
777
783
  heartbeatMs: beat * 1000,
784
+ identity: { version, home: home() },
785
+ allowHosts: opts.allowHost,
778
786
  factory: {
779
787
  list: () => listProjects(home()).map((item) => item.name),
780
788
  watch: (name, onFrame) => createStatusWatcher({
@@ -784,10 +792,38 @@ program
784
792
  debounceMs: 100,
785
793
  allTasks: true,
786
794
  }),
787
- quota: () => Promise.all([claudeQuota(), codexQuota()]),
795
+ quota: (signal) => Promise.all([claudeQuota(signal), codexQuota(signal)]),
788
796
  summarize: ({ runtime, prompt }) => summarizeProgress(runtime, prompt),
789
797
  },
790
- });
798
+ };
799
+ if (opts.exitWithParent) {
800
+ const codeFile = fileURLToPath(import.meta.url);
801
+ const changed = watchCodeChange(() => codeFingerprint(path.dirname(codeFile), path.extname(codeFile)));
802
+ await runAttachedWeb({
803
+ start: () => mod.startWebServer(startOptions),
804
+ port,
805
+ ppid: () => process.ppid,
806
+ startPpid: LAUNCH_PPID,
807
+ stdout: process.stdout,
808
+ stdin: process.stdin,
809
+ onSignal: (sig, handler) => process.on(sig, handler),
810
+ writeErr: (line) => {
811
+ try {
812
+ fs.writeSync(2, line);
813
+ }
814
+ catch {
815
+ // A closed stderr must not prevent the exit.
816
+ }
817
+ },
818
+ exit: (code) => process.exit(code),
819
+ fail: (message) => fail(message),
820
+ probeHealth,
821
+ intervalMs: beat * 1000,
822
+ changed,
823
+ });
824
+ await new Promise(() => { });
825
+ }
826
+ const page = await mod.startWebServer(startOptions);
791
827
  console.log(page.url);
792
828
  const stop = (code) => {
793
829
  void page.close().finally(() => process.exit(code));
@@ -1,5 +1,6 @@
1
1
  export declare const WATCH_RESTART_EXIT = 75;
2
2
  export declare const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
3
+ export declare const WEB_RESTART_MESSAGE = "status --web: agent-lyceum code changed on disk; restart to load it";
3
4
  /** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
4
5
  export declare function codeFingerprint(dir: string, ext: string): string;
5
6
  /** Two equal reads debounce changes; they do not establish build completion. */
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  export const WATCH_RESTART_EXIT = 75;
4
4
  export const WATCH_RESTART_MESSAGE = "status --watch: agent-lyceum code changed on disk; restart to load it";
5
+ export const WEB_RESTART_MESSAGE = "status --web: agent-lyceum code changed on disk; restart to load it";
5
6
  /** Sorted relative paths, sizes and mtimes; unreadable trees never trigger a restart. */
6
7
  export function codeFingerprint(dir, ext) {
7
8
  const files = [];
@@ -11,6 +11,8 @@ export interface MonitorDeps {
11
11
  resume: () => unknown;
12
12
  };
13
13
  ppid: () => number;
14
+ /** The parent pid recorded before any await; when absent the pid at call time is used. */
15
+ startPpid?: number;
14
16
  exit: (code: number) => void;
15
17
  onSignal: (sig: NodeJS.Signals, handler: () => void) => void;
16
18
  fail: (message: string) => void;
@@ -1,7 +1,7 @@
1
1
  const SIGNAL_CODES = [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]];
2
2
  /** Run `draw` every interval and end the process when the monitor is closed: signal, dead stdout, TTY stdin close, or lost parent. */
3
3
  export function runMonitor(d) {
4
- const startPpid = d.ppid();
4
+ const startPpid = d.startPpid ?? d.ppid();
5
5
  let timer;
6
6
  let done = false;
7
7
  const stop = (code) => {
package/dist/prompt.js CHANGED
@@ -13,6 +13,25 @@ function memoryLabel(agent, dir) {
13
13
  const kind = dir === (agent.memory.global ?? agent.sharedMemory?.global) ? "global (all projects" : "project (all tasks of this project";
14
14
  return shared ? `${kind}, read-only for you: ${instance1} maintains it)` : `${kind})`;
15
15
  }
16
+ /** Escape a value for a double-quoted attribute of the tags that wrap injected data. */
17
+ function attr(v) {
18
+ return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
19
+ }
20
+ /** One memory directory as a `<memory>` block: its MEMORY.md index, or a hint to create one. */
21
+ function memoryBlock(agent, dir) {
22
+ const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
23
+ const scope = memoryLabel(agent, dir).split(" ")[0];
24
+ const out = [`<memory scope="${attr(scope ?? "")}" dir="${attr(dir)}" index="MEMORY.md">`];
25
+ if (!idx)
26
+ out.push("(no MEMORY.md yet — create one when you have something worth keeping)");
27
+ else {
28
+ out.push(idx.text.trim());
29
+ if (idx.truncated)
30
+ out.push(`\n[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`);
31
+ }
32
+ out.push("</memory>");
33
+ return out;
34
+ }
16
35
  /** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
17
36
  export const WAKE_ECONOMY_SECTION = [
18
37
  "## Keeping wake-ups few",
@@ -132,15 +151,9 @@ export function buildSoloSystemPrompt(project, agent) {
132
151
  /** The prompt of a `call`: the memory indexes the agent is used to, then the task. */
133
152
  export function buildCallUserPrompt(agent, task) {
134
153
  const parts = [];
135
- for (const dir of memoryDirs(agent)) {
136
- const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
137
- parts.push(`# Your ${memoryLabel(agent, dir).split(" ")[0]} memory index (${dir}/MEMORY.md)`);
138
- if (!idx)
139
- parts.push("(no MEMORY.md yet — create one when you have something worth keeping)", "");
140
- else
141
- parts.push(idx.text.trim(), ...(idx.truncated ? [`[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`] : []), "");
142
- }
143
- parts.push("# Task", task.trim());
154
+ for (const dir of memoryDirs(agent))
155
+ parts.push(...memoryBlock(agent, dir), "");
156
+ parts.push("<task>", task.trim(), "</task>");
144
157
  return parts.join("\n");
145
158
  }
146
159
  /** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
@@ -269,7 +282,7 @@ export function pickMessages(project, agent, queue) {
269
282
  export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff = false) {
270
283
  const parts = [];
271
284
  const common = readCapped(commonFile(project), COMMON_LIMIT);
272
- parts.push("# Shared team context (COMMON.md)");
285
+ parts.push('<shared_context source="COMMON.md">');
273
286
  if (!common)
274
287
  parts.push("(empty)");
275
288
  else {
@@ -278,56 +291,48 @@ export function buildUserPrompt(project, agent, unread, handleCount = 1, handoff
278
291
  parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
279
292
  }
280
293
  }
281
- for (const [title, kind] of [
282
- ["Project decisions", "decisions"],
283
- ["Project lessons", "lessons"],
294
+ parts.push("</shared_context>");
295
+ for (const [tag, kind] of [
296
+ ["project_decisions", "decisions"],
297
+ ["project_lessons", "lessons"],
284
298
  ]) {
285
299
  const view = buildRecordsView(project, kind, CONTEXT_LIMIT);
286
300
  if (view)
287
- parts.push("", `# ${title}`, view); // nothing recorded yet: no heading, no tokens
301
+ parts.push("", `<${tag}>`, view, `</${tag}>`); // nothing recorded yet: no tag, no tokens
288
302
  }
289
303
  const handoffFile = handoff ? sharedIndex(project) : undefined;
290
304
  const notes = handoffFile ? readCapped(handoffFile, HANDOFF_LIMIT) : undefined;
291
305
  if (handoffFile && notes?.text.trim()) {
292
- parts.push("", `# Handoff notes from earlier segments of this task (${handoffFile})`, notes.text.trim());
306
+ parts.push("", `<handoff_notes from="earlier segments of this task" file="${attr(handoffFile)}">`, notes.text.trim());
293
307
  if (notes.truncated)
294
308
  parts.push(`\n[handoff index truncated at ${HANDOFF_LIMIT} bytes — the lead should condense it.]`);
295
- parts.push("The other files in that folder are there to read on demand.");
296
- }
297
- for (const dir of [...Object.values(agent.sharedMemory ?? {}), ...memoryDirs(agent)]) {
298
- const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
299
- const label = memoryLabel(agent, dir).split(" ")[0];
300
- parts.push("", `# Your ${label} memory index (${dir}/MEMORY.md)`);
301
- if (!idx)
302
- parts.push("(no MEMORY.md yet — create one when you have something worth keeping)");
303
- else {
304
- parts.push(idx.text.trim());
305
- if (idx.truncated)
306
- parts.push(`\n[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`);
307
- }
309
+ parts.push("The other files in that folder are there to read on demand.", "</handoff_notes>");
308
310
  }
311
+ for (const dir of [...Object.values(agent.sharedMemory ?? {}), ...memoryDirs(agent)])
312
+ parts.push("", ...memoryBlock(agent, dir));
309
313
  const sorted = [...unread].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
310
314
  const current = sorted.slice(0, handleCount);
311
315
  const queued = sorted.slice(handleCount);
312
- parts.push("", current.length > 1 ? `# Messages to handle now (${current.length}, oldest first — decide on them together)` : "# Message to handle now");
316
+ parts.push("", current.length > 1 ? `<messages count="${current.length}" order="oldest first" note="decide on them together">` : `<messages count="${current.length}">`);
313
317
  if (current.length === 0)
314
318
  parts.push("(no message)");
315
319
  let remaining = MAIL_BATCH_LIMIT;
316
320
  current.forEach((m, i) => {
317
- if (current.length > 1)
318
- parts.push("", `## Message ${i + 1} of ${current.length}`);
321
+ parts.push(current.length > 1 ? `<message n="${i + 1}" of="${current.length}">` : "<message>");
319
322
  const raw = Buffer.from(m.body, "utf8");
320
323
  const shown = cutUtf8(raw, Math.min(MAIL_BODY_LIMIT, remaining));
321
324
  remaining -= shown.length;
322
325
  const body = shown.length === raw.length
323
326
  ? m.body
324
327
  : `${shown.toString("utf8")}\n[agent-lyceum: this message was cut at ${shown.length} of ${raw.length} bytes; read the whole message in \`${m.file}\` (it moves to \`read/\` in the same folder after this wake-up)]`;
325
- parts.push(`id: ${m.meta.id}`, `from: ${senderLabel(m.meta.from, project.agents)}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body);
328
+ parts.push(`id: ${m.meta.id}`, `from: ${senderLabel(m.meta.from, project.agents)}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", body, "</message>");
326
329
  });
330
+ parts.push("</messages>");
327
331
  if (queued.length) {
328
- parts.push("", "# Queued messages (not handled in this wake-up; each gets its own wake-up later, titles only)");
332
+ parts.push("", '<queued_messages note="not handled in this wake-up; each gets its own wake-up later, titles only">');
329
333
  for (const m of queued)
330
334
  parts.push(`- from ${senderLabel(m.meta.from, project.agents)} [${m.meta.type}] "${m.meta.subject}" -> ${m.file}`);
335
+ parts.push("</queued_messages>");
331
336
  }
332
337
  return parts.join("\n");
333
338
  }
package/dist/quota.d.ts CHANGED
@@ -20,6 +20,6 @@ type RateLimits = {
20
20
  secondary?: LimitWindow | null;
21
21
  };
22
22
  export declare function codexWindows(limits: RateLimits): QuotaWindow[];
23
- export declare function claudeQuota(): Promise<QuotaReport>;
24
- export declare function codexQuota(): Promise<QuotaReport>;
23
+ export declare function claudeQuota(signal?: AbortSignal): Promise<QuotaReport>;
24
+ export declare function codexQuota(signal?: AbortSignal): Promise<QuotaReport>;
25
25
  export {};
package/dist/quota.js CHANGED
@@ -36,27 +36,35 @@ function windowOf(label, used, resets) {
36
36
  function roundPercent(n) {
37
37
  return Math.round(n * 10) / 10;
38
38
  }
39
- export async function claudeQuota() {
39
+ export async function claudeQuota(signal) {
40
40
  try {
41
- const text = await capture("claude", ["-p", "--no-session-persistence", "--output-format", "text", "/usage"], 20_000);
41
+ const text = await capture("claude", ["-p", "--no-session-persistence", "--output-format", "text", "/usage"], 20_000, signal);
42
42
  const windows = parseClaudeUsage(text.stdout);
43
43
  if (windows.length === 0)
44
44
  return { runtime: "claude", windows: [], error: firstLine(text.stderr) || firstLine(text.stdout) || "claude /usage 沒有回傳額度。" };
45
45
  return { runtime: "claude", windows };
46
46
  }
47
47
  catch (error) {
48
+ if (isAbort(error))
49
+ throw error;
48
50
  return { runtime: "claude", windows: [], error: missing("claude", error) };
49
51
  }
50
52
  }
51
- export async function codexQuota() {
53
+ export async function codexQuota(signal) {
52
54
  try {
53
- const limits = await readCodexLimits();
55
+ const limits = await readCodexLimits(signal);
54
56
  return { runtime: "codex", windows: codexWindows(limits) };
55
57
  }
56
58
  catch (error) {
59
+ if (isAbort(error))
60
+ throw error;
57
61
  return { runtime: "codex", windows: [], error: missing("codex", error) };
58
62
  }
59
63
  }
64
+ /** An aborted quota read is a cancelled request, not a report: callers (the hub) discard it. */
65
+ function isAbort(error) {
66
+ return error.name === "AbortError";
67
+ }
60
68
  function missing(cmd, error) {
61
69
  const code = error.code;
62
70
  if (code === "ENOENT")
@@ -66,9 +74,9 @@ function missing(cmd, error) {
66
74
  function firstLine(text) {
67
75
  return text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
68
76
  }
69
- function capture(cmd, args, timeoutMs) {
77
+ function capture(cmd, args, timeoutMs, signal) {
70
78
  return new Promise((resolve, reject) => {
71
- const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
79
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...(signal ? { signal } : {}) });
72
80
  let stdout = "";
73
81
  let stderr = "";
74
82
  const timer = setTimeout(() => {
@@ -87,9 +95,9 @@ function capture(cmd, args, timeoutMs) {
87
95
  });
88
96
  });
89
97
  }
90
- function readCodexLimits() {
98
+ function readCodexLimits(signal) {
91
99
  return new Promise((resolve, reject) => {
92
- const child = spawn("codex", ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"] });
100
+ const child = spawn("codex", ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], ...(signal ? { signal } : {}) });
93
101
  let buf = "";
94
102
  const timer = setTimeout(() => {
95
103
  child.kill();
@@ -0,0 +1,47 @@
1
+ import type { EventEmitter } from "node:events";
2
+ import { type MonitorDeps } from "./monitor-lifecycle.js";
3
+ export type HealthProbe = {
4
+ kind: "ours";
5
+ pid: number;
6
+ home: string;
7
+ } | {
8
+ kind: "foreign";
9
+ } | {
10
+ kind: "timeout";
11
+ };
12
+ export interface AttachedWebDeps {
13
+ start: () => Promise<{
14
+ url: string;
15
+ close: () => Promise<void>;
16
+ }>;
17
+ /** The port that was asked for; `probeHealth` is called with it when binding fails because it is taken. */
18
+ port: number;
19
+ ppid: () => number;
20
+ /** The parent pid recorded before anything was awaited. */
21
+ startPpid: number;
22
+ stdout: EventEmitter & {
23
+ write: (chunk: string) => unknown;
24
+ destroyed?: boolean;
25
+ writable?: boolean;
26
+ };
27
+ stdin: MonitorDeps["stdin"];
28
+ onSignal: MonitorDeps["onSignal"];
29
+ /** Writes one complete line (with its newline) to stderr. */
30
+ writeErr: (line: string) => void;
31
+ exit: (code: number) => void;
32
+ fail: (message: string) => void;
33
+ probeHealth: (port: number) => Promise<HealthProbe>;
34
+ intervalMs: number;
35
+ /** True once the code on disk changed (`watchCodeChange`). */
36
+ changed?: () => boolean;
37
+ /** Replaceable in tests. */
38
+ monitor?: (d: MonitorDeps) => void;
39
+ }
40
+ /** Ask `GET /health` on a taken port who holds it. Aborts after 1 s; an aborted or silent holder is `timeout`. */
41
+ export declare function probeHealth(port: number): Promise<HealthProbe>;
42
+ /**
43
+ * `status --web --exit-with-parent`: the page lives and dies with the launcher.
44
+ * The parent pid is checked before the server starts, stop signals are caught while it starts, and the server
45
+ * is closed (never left behind) on every path that ends the process.
46
+ */
47
+ export declare function runAttachedWeb(d: AttachedWebDeps): Promise<void>;
@@ -0,0 +1,102 @@
1
+ import http from "node:http";
2
+ import { WATCH_RESTART_EXIT, WEB_RESTART_MESSAGE } from "./code-change.js";
3
+ import { runMonitor } from "./monitor-lifecycle.js";
4
+ const SIGNAL_CODES = [["SIGINT", 130], ["SIGTERM", 143], ["SIGHUP", 129]];
5
+ /** Ask `GET /health` on a taken port who holds it. Aborts after 1 s; an aborted or silent holder is `timeout`. */
6
+ export async function probeHealth(port) {
7
+ const signal = AbortSignal.timeout(1000);
8
+ return new Promise((resolve) => {
9
+ const timeout = () => resolve({ kind: "timeout" });
10
+ const req = http.get({ host: "127.0.0.1", port, path: "/health", signal }, (res) => {
11
+ let body = "";
12
+ res.setEncoding("utf8");
13
+ res.on("data", (chunk) => {
14
+ body += chunk;
15
+ if (body.length > 65_536)
16
+ res.destroy();
17
+ });
18
+ res.on("error", () => (signal.aborted ? timeout() : resolve({ kind: "foreign" })));
19
+ res.on("end", () => {
20
+ try {
21
+ const j = JSON.parse(body);
22
+ if (j.app === "agent-lyceum-web" && typeof j.pid === "number")
23
+ return resolve({ kind: "ours", pid: j.pid, home: String(j.home ?? "") });
24
+ }
25
+ catch {
26
+ /* not ours */
27
+ }
28
+ resolve({ kind: "foreign" });
29
+ });
30
+ });
31
+ req.on("error", () => (signal.aborted ? timeout() : resolve({ kind: "foreign" })));
32
+ });
33
+ }
34
+ /**
35
+ * `status --web --exit-with-parent`: the page lives and dies with the launcher.
36
+ * The parent pid is checked before the server starts, stop signals are caught while it starts, and the server
37
+ * is closed (never left behind) on every path that ends the process.
38
+ */
39
+ export async function runAttachedWeb(d) {
40
+ if (d.ppid() !== d.startPpid)
41
+ return d.exit(129);
42
+ let stopCode;
43
+ let started = false;
44
+ for (const [sig, code] of SIGNAL_CODES) {
45
+ d.onSignal(sig, () => {
46
+ if (!started)
47
+ stopCode ??= code;
48
+ });
49
+ }
50
+ d.stdout.on("error", (e) => {
51
+ if (!started && (e.code === "EPIPE" || e.code === "EIO"))
52
+ stopCode ??= 0;
53
+ });
54
+ let page;
55
+ try {
56
+ page = await d.start();
57
+ }
58
+ catch (error) {
59
+ const message = error.message;
60
+ const taken = /already in use/.test(message);
61
+ if (!taken) {
62
+ d.writeErr(`status --web: ${message}\n`);
63
+ return d.exit(1);
64
+ }
65
+ const holder = await d.probeHealth(d.port);
66
+ if (holder.kind === "ours")
67
+ d.writeErr(`status --web: port ${d.port} in use by agent-lyceum-web pid ${holder.pid} home ${holder.home}\n`);
68
+ else if (holder.kind === "foreign")
69
+ d.writeErr(`status --web: port ${d.port} in use by another program\n`);
70
+ else
71
+ d.writeErr(`status --web: port ${d.port} in use, no answer in 1 s\n`);
72
+ return d.exit(1);
73
+ }
74
+ const closeThenExit = (code) => void page.close().then(() => d.exit(code), () => d.exit(code));
75
+ if (stopCode !== undefined)
76
+ return closeThenExit(stopCode);
77
+ if (d.ppid() !== d.startPpid)
78
+ return closeThenExit(129);
79
+ started = true;
80
+ d.stdout.write(`${page.url}\n`);
81
+ let stopping = false;
82
+ (d.monitor ?? runMonitor)({
83
+ draw: () => {
84
+ if (stopping)
85
+ return;
86
+ d.stdout.write("\n");
87
+ if (d.changed?.()) {
88
+ stopping = true;
89
+ d.writeErr(`${WEB_RESTART_MESSAGE}\n`);
90
+ closeThenExit(WATCH_RESTART_EXIT);
91
+ }
92
+ },
93
+ intervalMs: d.intervalMs,
94
+ stdout: d.stdout,
95
+ stdin: d.stdin,
96
+ ppid: d.ppid,
97
+ startPpid: d.startPpid,
98
+ exit: closeThenExit,
99
+ onSignal: d.onSignal,
100
+ fail: d.fail,
101
+ });
102
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.34.0",
3
+ "version": "0.36.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",
@@ -0,0 +1,65 @@
1
+ /** Host and Origin checks against DNS rebinding and cross-site `POST /quota`. Pure: no sockets, no node imports. */
2
+ const LOOPBACK_NAMES = ["127.0.0.1", "localhost", "::1"];
3
+ /** Trim, lowercase, drop `:port` or `[v6]:port` brackets and one trailing dot. Undefined when there is nothing usable. */
4
+ export function normalizeHost(value) {
5
+ let host = (value ?? "").trim().toLowerCase();
6
+ if (!host)
7
+ return undefined;
8
+ if (host.startsWith("[")) {
9
+ const end = host.indexOf("]");
10
+ if (end < 0)
11
+ return undefined;
12
+ host = host.slice(1, end);
13
+ }
14
+ else if ((host.match(/:/g) ?? []).length === 1) {
15
+ host = host.slice(0, host.indexOf(":"));
16
+ }
17
+ if (host.endsWith("."))
18
+ host = host.slice(0, -1);
19
+ return host || undefined;
20
+ }
21
+ export function isLoopbackHost(host) {
22
+ const name = normalizeHost(host);
23
+ if (!name)
24
+ return false;
25
+ return name === "localhost" || name === "::1" || /^127\.\d+\.\d+\.\d+$/.test(name);
26
+ }
27
+ function allowed(opts) {
28
+ const names = new Set(LOOPBACK_NAMES);
29
+ for (const name of [opts.bindHost, ...opts.allowHosts]) {
30
+ const n = normalizeHost(name);
31
+ if (n)
32
+ names.add(n);
33
+ }
34
+ return names;
35
+ }
36
+ /** The HTTP status to reject with (400 or 403), or undefined when the request may proceed. */
37
+ export function checkRequest(req, opts) {
38
+ if (!isLoopbackHost(opts.bindHost))
39
+ return undefined;
40
+ const names = allowed(opts);
41
+ const host = normalizeHost(req.host);
42
+ if (!host)
43
+ return 400;
44
+ if (!names.has(host))
45
+ return 403;
46
+ if (req.method === "POST" && req.pathname === "/quota" && req.origin !== undefined) {
47
+ if (req.origin === "null")
48
+ return 403;
49
+ let url;
50
+ try {
51
+ url = new URL(req.origin);
52
+ }
53
+ catch {
54
+ return 403;
55
+ }
56
+ if (url.protocol !== "http:")
57
+ return 403;
58
+ const name = normalizeHost(url.hostname);
59
+ if (!name || !names.has(name))
60
+ return 403;
61
+ if (Number(url.port || 80) !== opts.port)
62
+ return 403;
63
+ }
64
+ return undefined;
65
+ }
@@ -4,6 +4,7 @@ export function createWebHub(factory) {
4
4
  const listeners = new Set();
5
5
  let quota;
6
6
  let quotaToken = 0;
7
+ const quotaAbort = new AbortController();
7
8
  const emit = (event) => {
8
9
  for (const listener of listeners) {
9
10
  try {
@@ -61,7 +62,7 @@ export function createWebHub(factory) {
61
62
  return Promise.resolve(quota);
62
63
  const token = ++quotaToken;
63
64
  return factory
64
- .quota()
65
+ .quota(quotaAbort.signal)
65
66
  .then((reports) => {
66
67
  if (token !== quotaToken)
67
68
  return quota;
@@ -80,6 +81,7 @@ export function createWebHub(factory) {
80
81
  },
81
82
  close() {
82
83
  quotaToken += 1;
84
+ quotaAbort.abort(); // asks in-flight quota children to end (SIGTERM); it does not wait for them
83
85
  for (const watch of watchers.values())
84
86
  watch.close();
85
87
  watchers.clear();
package/web/dist/start.js CHANGED
@@ -1,15 +1,35 @@
1
1
  import { createWebHub } from "./server/hub.js";
2
- import { attachEvents, listen, serveStatic } from "./server/http.js";
2
+ import { checkRequest } from "./server/guard.js";
3
+ import { attachEvents, listen as listenHttp, serveStatic } from "./server/http.js";
3
4
  import { openBrowser } from "./server/open.js";
4
5
  import { handleSummary } from "./server/summary.js";
6
+ const JSON_HEADERS = { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" };
7
+ /** Binds first; the hub, the quota reads and the timers start only once the port is ours, and are undone if that fails. */
5
8
  export async function startWebServer(opts) {
6
9
  const hub = createWebHub(opts.factory);
7
- hub.start();
8
- hub.refreshQuota();
9
- const heartbeat = setInterval(() => hub.beat(), opts.heartbeatMs ?? 5_000);
10
- const quotaBeat = setInterval(() => hub.refreshQuota(), 60_000);
11
- const { server, sockets } = await listen(opts.port, opts.host, (req, res) => {
10
+ const startedAt = new Date().toISOString();
11
+ let boundPort = opts.port;
12
+ const { server, sockets } = await (opts.listen ?? listenHttp)(opts.port, opts.host, (req, res) => {
12
13
  const pathname = (req.url ?? "/").split("?")[0] ?? "/";
14
+ const origin = req.headers.origin;
15
+ const rejected = checkRequest({ method: req.method, pathname, host: req.headers.host, origin: Array.isArray(origin) ? origin[0] : origin }, { bindHost: opts.host, allowHosts: opts.allowHosts ?? [], port: boundPort });
16
+ if (rejected) {
17
+ res.writeHead(rejected);
18
+ res.end();
19
+ return;
20
+ }
21
+ if (pathname === "/health" && (req.method === "GET" || req.method === "HEAD")) {
22
+ res.writeHead(200, JSON_HEADERS);
23
+ res.end(JSON.stringify({
24
+ app: "agent-lyceum-web",
25
+ version: opts.identity?.version ?? "unknown",
26
+ pid: process.pid,
27
+ ppid: process.ppid,
28
+ home: opts.identity?.home ?? "",
29
+ started_at: startedAt,
30
+ }));
31
+ return;
32
+ }
13
33
  if (pathname === "/events") {
14
34
  attachEvents(hub, req, res);
15
35
  return;
@@ -18,14 +38,14 @@ export async function startWebServer(opts) {
18
38
  void handleSummary(req, res, hub, opts.factory.summarize).catch(() => {
19
39
  if (res.headersSent)
20
40
  return;
21
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
41
+ res.writeHead(500, JSON_HEADERS);
22
42
  res.end(JSON.stringify({ error: "沒有寫出摘要。" }));
23
43
  });
24
44
  return;
25
45
  }
26
46
  if (pathname === "/quota" && req.method === "POST") {
27
47
  void hub.refreshQuota().then((reports) => {
28
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
48
+ res.writeHead(200, JSON_HEADERS);
29
49
  res.end(JSON.stringify(reports ?? []));
30
50
  }, () => {
31
51
  if (res.headersSent)
@@ -43,26 +63,36 @@ export async function startWebServer(opts) {
43
63
  serveStatic(opts.staticDir, req, res);
44
64
  });
45
65
  const address = server.address();
46
- const port = typeof address === "object" && address ? address.port : opts.port;
66
+ boundPort = typeof address === "object" && address ? address.port : opts.port;
67
+ let heartbeat;
68
+ let quotaBeat;
69
+ const close = () => new Promise((resolve, reject) => {
70
+ clearInterval(heartbeat);
71
+ clearInterval(quotaBeat);
72
+ hub.close();
73
+ for (const socket of sockets)
74
+ socket.destroy();
75
+ server.close((err) => {
76
+ if (err && err.code !== "ERR_SERVER_NOT_RUNNING")
77
+ reject(err);
78
+ else
79
+ resolve();
80
+ });
81
+ });
47
82
  const host = opts.host === "0.0.0.0" || opts.host === "::" ? "127.0.0.1" : opts.host;
48
83
  const shown = host.includes(":") ? `[${host}]` : host;
49
- const url = `http://${shown}:${port}/`;
50
- if (opts.open)
51
- openBrowser(url);
52
- return {
53
- url,
54
- close: () => new Promise((resolve, reject) => {
55
- clearInterval(heartbeat);
56
- clearInterval(quotaBeat);
57
- hub.close();
58
- for (const socket of sockets)
59
- socket.destroy();
60
- server.close((err) => {
61
- if (err && err.code !== "ERR_SERVER_NOT_RUNNING")
62
- reject(err);
63
- else
64
- resolve();
65
- });
66
- }),
67
- };
84
+ const url = `http://${shown}:${boundPort}/`;
85
+ try {
86
+ hub.start();
87
+ void hub.refreshQuota();
88
+ heartbeat = setInterval(() => hub.beat(), opts.heartbeatMs ?? 5_000);
89
+ quotaBeat = setInterval(() => void hub.refreshQuota(), 60_000);
90
+ if (opts.open)
91
+ openBrowser(url);
92
+ }
93
+ catch (error) {
94
+ await close().catch(() => { });
95
+ throw error;
96
+ }
97
+ return { url, close };
68
98
  }