agent-lyceum 0.28.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/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/cli.js +67 -2
- package/dist/code-change.d.ts +6 -0
- package/dist/code-change.js +37 -0
- package/dist/project-lock.d.ts +17 -1
- package/dist/project-lock.js +93 -31
- package/dist/quota.d.ts +25 -0
- package/dist/quota.js +141 -0
- package/dist/status-watch.d.ts +9 -5
- package/dist/status-watch.js +10 -8
- package/dist/status.d.ts +2 -2
- package/dist/status.js +2 -2
- package/package.json +5 -3
- package/web/dist/public/assets/index-B1Ld6Pl5.css +2 -0
- package/web/dist/public/assets/index-D2JeeQpl.js +9 -0
- package/web/dist/public/index.html +13 -0
- package/web/dist/server/http.js +94 -0
- package/web/dist/server/hub.js +97 -0
- package/web/dist/server/open.js +9 -0
- package/web/dist/start.js +58 -0
- package/web/dist/types.js +1 -0
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. [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. [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
|
|
52
|
+
| `status` | 顯示 agent、信件、目前的喚醒與過去的 run。`status --web` 在瀏覽器看全部已註冊專案。 [詳細](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
|
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import readline from "node:readline/promises";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
7
|
import { Command } from "commander";
|
|
7
8
|
import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
|
|
8
9
|
import { openInEditor, prepareAnswers } from "./ask-answer.js";
|
|
@@ -23,10 +24,12 @@ import { callAgent, callProject, callsDir, resolveCallAgent } from "./call.js";
|
|
|
23
24
|
import { formatEnforcement, validateProject } from "./validate.js";
|
|
24
25
|
import { must } from "./assert.js";
|
|
25
26
|
import { runMonitor } from "./monitor-lifecycle.js";
|
|
27
|
+
import { codeFingerprint, watchCodeChange, WATCH_RESTART_EXIT, WATCH_RESTART_MESSAGE } from "./code-change.js";
|
|
26
28
|
import { createStatusWatcher } from "./status-watch.js";
|
|
27
29
|
import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
|
|
28
30
|
import { onlyAgent, soloProject } from "./solo.js";
|
|
29
31
|
import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
|
|
32
|
+
import { claudeQuota, codexQuota } from "./quota.js";
|
|
30
33
|
const program = new Command();
|
|
31
34
|
program
|
|
32
35
|
.name("agent-lyceum")
|
|
@@ -764,9 +767,57 @@ program
|
|
|
764
767
|
.option("--interval <sec>", "refresh interval for --monitor", "2")
|
|
765
768
|
.option("--json", "print the report as JSON (no colour); works with --task-list and --task-id")
|
|
766
769
|
.option("--watch", "with --json: keep running and print one JSON line (the project report plus one per running task) whenever it changes (Ctrl-C to quit)")
|
|
767
|
-
.option("--heartbeat <sec>", "with --watch: how often to re-check when no file event came", "5")
|
|
770
|
+
.option("--heartbeat <sec>", "with --watch or --web: how often to re-check when no file event came", "5")
|
|
771
|
+
.option("--web", "open a local page for every registered project")
|
|
772
|
+
.option("--port <n>", "with --web: TCP port", "8787")
|
|
773
|
+
.option("--host <addr>", "with --web: bind address", "127.0.0.1")
|
|
774
|
+
.option("--no-open", "with --web: do not open a browser")
|
|
768
775
|
.action(async (opts) => {
|
|
769
776
|
try {
|
|
777
|
+
if (opts.web) {
|
|
778
|
+
if (opts.json || opts.watch || opts.monitor || opts.taskList || opts.taskId !== undefined)
|
|
779
|
+
fail("--web cannot be combined with --json, --watch, --monitor, --task-list or --task-id.");
|
|
780
|
+
if (opts.project)
|
|
781
|
+
fail("--web shows every registered project; it does not take --project.");
|
|
782
|
+
const port = Number(opts.port);
|
|
783
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
784
|
+
fail("--port must be a TCP port.");
|
|
785
|
+
const beat = Number(opts.heartbeat);
|
|
786
|
+
if (!(beat > 0))
|
|
787
|
+
fail("--heartbeat must be a positive number of seconds.");
|
|
788
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
789
|
+
const entry = path.join(root, "web", "dist", "start.js");
|
|
790
|
+
const staticDir = path.join(root, "web", "dist", "public");
|
|
791
|
+
if (!fs.existsSync(entry) || !fs.existsSync(path.join(staticDir, "index.html"))) {
|
|
792
|
+
fail(`The web monitor is not built.\nFrom the repo: pnpm --dir web install && pnpm --dir web build\nExpected ${entry}`);
|
|
793
|
+
}
|
|
794
|
+
const mod = (await import(pathToFileURL(entry).href));
|
|
795
|
+
const page = await mod.startWebServer({
|
|
796
|
+
port,
|
|
797
|
+
host: opts.host,
|
|
798
|
+
open: opts.open !== false,
|
|
799
|
+
staticDir,
|
|
800
|
+
heartbeatMs: beat * 1000,
|
|
801
|
+
factory: {
|
|
802
|
+
list: () => listProjects(home()).map((item) => item.name),
|
|
803
|
+
watch: (name, onFrame) => createStatusWatcher({
|
|
804
|
+
load: () => resolveProject(home(), name),
|
|
805
|
+
dir: (project) => project.paths.runs,
|
|
806
|
+
write: (line) => onFrame(JSON.parse(line)),
|
|
807
|
+
debounceMs: 100,
|
|
808
|
+
allTasks: true,
|
|
809
|
+
}),
|
|
810
|
+
quota: () => Promise.all([claudeQuota(), codexQuota()]),
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
console.log(page.url);
|
|
814
|
+
const stop = (code) => {
|
|
815
|
+
void page.close().finally(() => process.exit(code));
|
|
816
|
+
};
|
|
817
|
+
process.on("SIGINT", () => stop(130));
|
|
818
|
+
process.on("SIGTERM", () => stop(143));
|
|
819
|
+
await new Promise(() => { });
|
|
820
|
+
}
|
|
770
821
|
if (opts.watch) {
|
|
771
822
|
if (!opts.json)
|
|
772
823
|
fail("--watch needs --json.");
|
|
@@ -782,8 +833,22 @@ program
|
|
|
782
833
|
write: (line) => process.stdout.write(`${line}\n`),
|
|
783
834
|
debounceMs: 100,
|
|
784
835
|
});
|
|
836
|
+
const codeFile = fileURLToPath(import.meta.url);
|
|
837
|
+
const changed = watchCodeChange(() => codeFingerprint(path.dirname(codeFile), path.extname(codeFile)));
|
|
785
838
|
runMonitor({
|
|
786
|
-
draw:
|
|
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
|
+
},
|
|
787
852
|
intervalMs: beat * 1000,
|
|
788
853
|
stdout: process.stdout,
|
|
789
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
|
+
}
|
package/dist/project-lock.d.ts
CHANGED
|
@@ -44,11 +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;
|
|
51
|
+
/** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
|
|
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;
|
|
47
63
|
/**
|
|
48
64
|
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
49
65
|
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
50
66
|
*/
|
|
51
|
-
export declare function lockHolderAlive(info: LockInfo): boolean;
|
|
67
|
+
export declare function lockHolderAlive(info: Pick<LockInfo, "pid" | "pid_started">, io?: LivenessIo): boolean;
|
|
52
68
|
/** Every lock of a project that can be read: one per run, plus an older version's `lock.json`. Oldest first. */
|
|
53
69
|
export declare function listRunLocks(root: string): LockInfo[];
|
|
54
70
|
/** Run ids whose per-run lock file exists but cannot be read (it can only be removed with `forceUnlock(root, runId)`). */
|
package/dist/project-lock.js
CHANGED
|
@@ -39,45 +39,107 @@ export function pidStartFrom(pid, io) {
|
|
|
39
39
|
}
|
|
40
40
|
return io.ps(pid);
|
|
41
41
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
try {
|
|
46
|
-
return fs.readFileSync(p, "utf8");
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
ps: (pid) => {
|
|
53
|
-
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
54
|
-
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
55
|
-
return out || undefined;
|
|
56
|
-
},
|
|
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;
|
|
57
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
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const realIo = { platform: process.platform, readFile: fileReader, ps: readerPs };
|
|
59
|
+
const writerIo = { ...realIo, ps: writerPs };
|
|
58
60
|
function pidStart(pid) {
|
|
59
|
-
return pidStartFrom(pid,
|
|
61
|
+
return pidStartFrom(pid, writerIo);
|
|
60
62
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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" };
|
|
64
|
+
/** `ps -o lstart` in the C locale, or the same instant from a Chinese macOS locale, as `YYYY-MM-DDTHH:mm:ss`. */
|
|
65
|
+
function canonicalLstart(text) {
|
|
66
|
+
const s = text.trim();
|
|
67
|
+
const en = /^[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(s);
|
|
68
|
+
if (en) {
|
|
69
|
+
const month = MONTHS[en[1] ?? ""];
|
|
70
|
+
if (!month)
|
|
71
|
+
return undefined;
|
|
72
|
+
return `${en[6]}-${month}-${(en[2] ?? "").padStart(2, "0")}T${en[3]}:${en[4]}:${en[5]}`;
|
|
73
|
+
}
|
|
74
|
+
const zh = /^\S+\s+(\d{1,2})月\/\s*(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(s);
|
|
75
|
+
if (zh)
|
|
76
|
+
return `${zh[6]}-${(zh[1] ?? "").padStart(2, "0")}-${(zh[2] ?? "").padStart(2, "0")}T${zh[3]}:${zh[4]}:${zh[5]}`;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
|
|
80
|
+
export function samePidStart(a, b) {
|
|
81
|
+
if (a === b)
|
|
82
|
+
return true;
|
|
83
|
+
const left = canonicalLstart(a);
|
|
84
|
+
const right = canonicalLstart(b);
|
|
85
|
+
return !!left && left === right;
|
|
86
|
+
}
|
|
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) {
|
|
68
91
|
try {
|
|
69
|
-
|
|
92
|
+
kill(pid, 0);
|
|
93
|
+
return true;
|
|
70
94
|
}
|
|
71
95
|
catch (e) {
|
|
72
|
-
|
|
73
|
-
return false;
|
|
96
|
+
return e?.code !== "ESRCH";
|
|
74
97
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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;
|
|
79
133
|
}
|
|
80
|
-
|
|
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;
|
|
81
143
|
}
|
|
82
144
|
function readLockFile(file) {
|
|
83
145
|
let text;
|
package/dist/quota.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type QuotaWindow = {
|
|
2
|
+
label: string;
|
|
3
|
+
used_percent: number;
|
|
4
|
+
remaining_percent: number;
|
|
5
|
+
resets: string;
|
|
6
|
+
};
|
|
7
|
+
export type QuotaReport = {
|
|
8
|
+
runtime: "claude" | "codex";
|
|
9
|
+
windows: QuotaWindow[];
|
|
10
|
+
error?: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function parseClaudeUsage(text: string): QuotaWindow[];
|
|
13
|
+
type LimitWindow = {
|
|
14
|
+
usedPercent?: number;
|
|
15
|
+
windowDurationMins?: number;
|
|
16
|
+
resetsAt?: number;
|
|
17
|
+
};
|
|
18
|
+
type RateLimits = {
|
|
19
|
+
primary?: LimitWindow | null;
|
|
20
|
+
secondary?: LimitWindow | null;
|
|
21
|
+
};
|
|
22
|
+
export declare function codexWindows(limits: RateLimits): QuotaWindow[];
|
|
23
|
+
export declare function claudeQuota(): Promise<QuotaReport>;
|
|
24
|
+
export declare function codexQuota(): Promise<QuotaReport>;
|
|
25
|
+
export {};
|
package/dist/quota.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export function parseClaudeUsage(text) {
|
|
3
|
+
const session = text.match(/Current session:\s*(\d+(?:\.\d+)?)%\s*used\s*·\s*resets\s+(.+)/);
|
|
4
|
+
const week = text.match(/Current week(?: \([^)]*\))?:\s*(\d+(?:\.\d+)?)%\s*used\s*·\s*resets\s+(.+)/);
|
|
5
|
+
const windows = [];
|
|
6
|
+
if (session)
|
|
7
|
+
windows.push(windowOf("這 5 小時", Number(session[1]), (session[2] ?? "").trim()));
|
|
8
|
+
if (week)
|
|
9
|
+
windows.push(windowOf("本週", Number(week[1]), (week[2] ?? "").trim()));
|
|
10
|
+
return windows;
|
|
11
|
+
}
|
|
12
|
+
export function codexWindows(limits) {
|
|
13
|
+
return [limits.primary, limits.secondary].flatMap((window) => {
|
|
14
|
+
if (!window || window.usedPercent === undefined)
|
|
15
|
+
return [];
|
|
16
|
+
const mins = window.windowDurationMins ?? 0;
|
|
17
|
+
const resets = window.resetsAt ? new Date(window.resetsAt * 1000).toLocaleString("zh-Hant", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "時間不明";
|
|
18
|
+
return [windowOf(durationLabel(mins), window.usedPercent, resets)];
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function durationLabel(mins) {
|
|
22
|
+
if (mins === 300)
|
|
23
|
+
return "這 5 小時";
|
|
24
|
+
if (mins === 10080)
|
|
25
|
+
return "7 天";
|
|
26
|
+
if (mins > 0 && mins % 1440 === 0)
|
|
27
|
+
return `${mins / 1440} 天`;
|
|
28
|
+
if (mins > 0 && mins % 60 === 0)
|
|
29
|
+
return `${mins / 60} 小時`;
|
|
30
|
+
return mins > 0 ? `${mins} 分鐘` : "用量";
|
|
31
|
+
}
|
|
32
|
+
function windowOf(label, used, resets) {
|
|
33
|
+
const usedPercent = roundPercent(used);
|
|
34
|
+
return { label, used_percent: usedPercent, remaining_percent: roundPercent(Math.max(0, 100 - used)), resets };
|
|
35
|
+
}
|
|
36
|
+
function roundPercent(n) {
|
|
37
|
+
return Math.round(n * 10) / 10;
|
|
38
|
+
}
|
|
39
|
+
export async function claudeQuota() {
|
|
40
|
+
try {
|
|
41
|
+
const text = await capture("claude", ["-p", "--no-session-persistence", "--output-format", "text", "/usage"], 20_000);
|
|
42
|
+
const windows = parseClaudeUsage(text.stdout);
|
|
43
|
+
if (windows.length === 0)
|
|
44
|
+
return { runtime: "claude", windows: [], error: firstLine(text.stderr) || firstLine(text.stdout) || "claude /usage 沒有回傳額度。" };
|
|
45
|
+
return { runtime: "claude", windows };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
return { runtime: "claude", windows: [], error: missing("claude", error) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function codexQuota() {
|
|
52
|
+
try {
|
|
53
|
+
const limits = await readCodexLimits();
|
|
54
|
+
return { runtime: "codex", windows: codexWindows(limits) };
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
return { runtime: "codex", windows: [], error: missing("codex", error) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function missing(cmd, error) {
|
|
61
|
+
const code = error.code;
|
|
62
|
+
if (code === "ENOENT")
|
|
63
|
+
return `找不到 ${cmd}。`;
|
|
64
|
+
return error.message || `${cmd} 沒有回傳額度。`;
|
|
65
|
+
}
|
|
66
|
+
function firstLine(text) {
|
|
67
|
+
return text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
|
|
68
|
+
}
|
|
69
|
+
function capture(cmd, args, timeoutMs) {
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
72
|
+
let stdout = "";
|
|
73
|
+
let stderr = "";
|
|
74
|
+
const timer = setTimeout(() => {
|
|
75
|
+
child.kill();
|
|
76
|
+
reject(new Error(`${cmd} 超過 ${Math.round(timeoutMs / 1000)} 秒沒有回應。`));
|
|
77
|
+
}, timeoutMs);
|
|
78
|
+
child.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
|
79
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
80
|
+
child.on("error", (error) => {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
reject(error);
|
|
83
|
+
});
|
|
84
|
+
child.on("close", () => {
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
resolve({ stdout, stderr });
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function readCodexLimits() {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const child = spawn("codex", ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
93
|
+
let buf = "";
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
child.kill();
|
|
96
|
+
reject(new Error("codex 超過 20 秒沒有回應。"));
|
|
97
|
+
}, 20_000);
|
|
98
|
+
let done = false;
|
|
99
|
+
const finish = (error, limits) => {
|
|
100
|
+
if (done)
|
|
101
|
+
return;
|
|
102
|
+
done = true;
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
child.kill();
|
|
105
|
+
if (error)
|
|
106
|
+
reject(error);
|
|
107
|
+
else
|
|
108
|
+
resolve(limits ?? {});
|
|
109
|
+
};
|
|
110
|
+
child.on("error", (error) => finish(error));
|
|
111
|
+
child.stdout.on("data", (chunk) => {
|
|
112
|
+
buf += String(chunk);
|
|
113
|
+
let cut = buf.indexOf("\n");
|
|
114
|
+
while (cut >= 0) {
|
|
115
|
+
const line = buf.slice(0, cut).trim();
|
|
116
|
+
buf = buf.slice(cut + 1);
|
|
117
|
+
cut = buf.indexOf("\n");
|
|
118
|
+
if (!line)
|
|
119
|
+
continue;
|
|
120
|
+
let message;
|
|
121
|
+
try {
|
|
122
|
+
message = JSON.parse(line);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (message.id === 1 && message.result) {
|
|
128
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} })}\n`);
|
|
129
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "account/rateLimits/read", params: {} })}\n`);
|
|
130
|
+
}
|
|
131
|
+
if (message.id === 2) {
|
|
132
|
+
if (message.error)
|
|
133
|
+
finish(new Error(message.error.message || "codex 沒有回傳額度。"));
|
|
134
|
+
else
|
|
135
|
+
finish(undefined, message.result?.rateLimits ?? {});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "agent-lyceum", version: "0" }, capabilities: {} } })}\n`);
|
|
140
|
+
});
|
|
141
|
+
}
|
package/dist/status-watch.d.ts
CHANGED
|
@@ -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,12 +8,14 @@ export type WatchFrame = {
|
|
|
8
8
|
error?: string;
|
|
9
9
|
};
|
|
10
10
|
/**
|
|
11
|
-
* The frame for now. Every task that is not
|
|
12
|
-
*
|
|
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
|
-
export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string
|
|
16
|
+
export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>, opts?: {
|
|
17
|
+
allTasks?: boolean;
|
|
18
|
+
}): WatchFrame;
|
|
17
19
|
export interface WatchDeps {
|
|
18
20
|
/** The project, loaded again each time so config edits and new runs show up. */
|
|
19
21
|
load: () => ResolvedProject;
|
|
@@ -21,6 +23,8 @@ export interface WatchDeps {
|
|
|
21
23
|
dir: (project: ResolvedProject) => string;
|
|
22
24
|
write: (line: string) => void;
|
|
23
25
|
debounceMs: number;
|
|
26
|
+
/** Include finished tasks. The browser page uses this; `status --json --watch` does not. */
|
|
27
|
+
allTasks?: boolean;
|
|
24
28
|
watch?: (dir: string, onChange: () => void) => {
|
|
25
29
|
close: () => void;
|
|
26
30
|
};
|
package/dist/status-watch.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import { buildStatusReport, buildTaskListReport,
|
|
2
|
+
import { buildStatusReport, buildTaskListReport, isFinishedRun } from "./status.js";
|
|
3
3
|
/**
|
|
4
|
-
* The frame for now. Every task that is not
|
|
5
|
-
*
|
|
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
|
-
export function buildWatchFrame(project, tracked) {
|
|
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) => !
|
|
12
|
+
const open = all.filter((r) => !isFinishedRun(r)).map((r) => r.run_id);
|
|
13
13
|
const runs = {};
|
|
14
|
-
|
|
14
|
+
// The web page keeps every task. The terminal watch drops one after it has finished, once the finish has been seen.
|
|
15
|
+
const ids = opts?.allTasks ? all.map((r) => r.run_id) : [...open, ...tracked];
|
|
16
|
+
for (const id of new Set(ids))
|
|
15
17
|
runs[id] = buildStatusReport(project, id);
|
|
16
18
|
const report = buildStatusReport(project);
|
|
17
19
|
tracked.clear();
|
|
@@ -39,7 +41,7 @@ export function createStatusWatcher(d) {
|
|
|
39
41
|
let project;
|
|
40
42
|
try {
|
|
41
43
|
project = d.load();
|
|
42
|
-
frame = buildWatchFrame(project, tracked);
|
|
44
|
+
frame = buildWatchFrame(project, tracked, { allTasks: d.allTasks });
|
|
43
45
|
}
|
|
44
46
|
catch (e) {
|
|
45
47
|
frame = { schema_version: 1, error: e.message };
|
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
|
|
177
|
-
export declare const
|
|
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
|
|
270
|
-
export const
|
|
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.
|
|
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",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"dist",
|
|
30
|
-
"!dist/**/*.map"
|
|
30
|
+
"!dist/**/*.map",
|
|
31
|
+
"web/dist",
|
|
32
|
+
"!web/dist/**/*.map"
|
|
31
33
|
],
|
|
32
34
|
"engines": {
|
|
33
35
|
"node": ">=20"
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
"typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:plugin",
|
|
42
44
|
"test": "vitest run",
|
|
43
45
|
"prepare": "npm run build",
|
|
44
|
-
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
46
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build && pnpm --dir web install --frozen-lockfile && pnpm --dir web typecheck && pnpm --dir web build",
|
|
45
47
|
"release": "bash scripts/release.sh",
|
|
46
48
|
"typecheck:plugin": "tsc -p plugins/status-monitor/tsconfig.ci.json",
|
|
47
49
|
"check:pack": "node scripts/check-pack.mjs",
|