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 +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/cli.js +41 -5
- package/dist/code-change.d.ts +1 -0
- package/dist/code-change.js +1 -0
- package/dist/monitor-lifecycle.d.ts +2 -0
- package/dist/monitor-lifecycle.js +1 -1
- package/dist/prompt.js +38 -33
- package/dist/quota.d.ts +2 -2
- package/dist/quota.js +16 -8
- package/dist/web-attach.d.ts +47 -0
- package/dist/web-attach.js +102 -0
- package/package.json +1 -1
- package/web/dist/server/guard.js +65 -0
- package/web/dist/server/hub.js +3 -1
- package/web/dist/start.js +58 -28
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`
|
|
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 <
|
|
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
|
|
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));
|
package/dist/code-change.d.ts
CHANGED
|
@@ -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. */
|
package/dist/code-change.js
CHANGED
|
@@ -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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
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
|
-
|
|
137
|
-
|
|
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("
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
["
|
|
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("",
|
|
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("",
|
|
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 ?
|
|
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
|
-
|
|
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("", "
|
|
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
|
@@ -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
|
+
}
|
package/web/dist/server/hub.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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}:${
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
}
|