agent-lyceum 0.4.9

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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +132 -0
  3. package/README.zh-TW.md +133 -0
  4. package/dist/adapters/claude.d.ts +14 -0
  5. package/dist/adapters/claude.js +155 -0
  6. package/dist/adapters/codex.d.ts +5 -0
  7. package/dist/adapters/codex.js +109 -0
  8. package/dist/adapters/index.d.ts +6 -0
  9. package/dist/adapters/index.js +20 -0
  10. package/dist/adapters/types.d.ts +61 -0
  11. package/dist/adapters/types.js +1 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +471 -0
  14. package/dist/config.d.ts +115 -0
  15. package/dist/config.js +253 -0
  16. package/dist/dispatcher.d.ts +37 -0
  17. package/dist/dispatcher.js +467 -0
  18. package/dist/doctor.d.ts +46 -0
  19. package/dist/doctor.js +151 -0
  20. package/dist/format.d.ts +33 -0
  21. package/dist/format.js +100 -0
  22. package/dist/fs-util.d.ts +2 -0
  23. package/dist/fs-util.js +9 -0
  24. package/dist/guard.d.ts +20 -0
  25. package/dist/guard.js +72 -0
  26. package/dist/mailbox.d.ts +97 -0
  27. package/dist/mailbox.js +259 -0
  28. package/dist/message-store.d.ts +91 -0
  29. package/dist/message-store.js +225 -0
  30. package/dist/paths.d.ts +30 -0
  31. package/dist/paths.js +62 -0
  32. package/dist/policy.d.ts +38 -0
  33. package/dist/policy.js +143 -0
  34. package/dist/process-runner.d.ts +21 -0
  35. package/dist/process-runner.js +165 -0
  36. package/dist/project-lock.d.ts +30 -0
  37. package/dist/project-lock.js +186 -0
  38. package/dist/prompt.d.ts +11 -0
  39. package/dist/prompt.js +125 -0
  40. package/dist/run-cleanup.d.ts +51 -0
  41. package/dist/run-cleanup.js +120 -0
  42. package/dist/run-store.d.ts +111 -0
  43. package/dist/run-store.js +119 -0
  44. package/dist/scaffold.d.ts +11 -0
  45. package/dist/scaffold.js +126 -0
  46. package/dist/schema.d.ts +303 -0
  47. package/dist/schema.js +84 -0
  48. package/dist/status.d.ts +142 -0
  49. package/dist/status.js +399 -0
  50. package/dist/task.d.ts +24 -0
  51. package/dist/task.js +71 -0
  52. package/dist/validate.d.ts +13 -0
  53. package/dist/validate.js +116 -0
  54. package/dist/worktree.d.ts +103 -0
  55. package/dist/worktree.js +395 -0
  56. package/package.json +58 -0
@@ -0,0 +1,61 @@
1
+ import type { ResolvedAgent, ResolvedProject } from "../config.js";
2
+ import type { AgentWorkspace } from "../worktree.js";
3
+ export interface WakeInput {
4
+ project: ResolvedProject;
5
+ agent: ResolvedAgent;
6
+ /** Scratch dir for generated per-agent files (inside the run dir, never in the repo). */
7
+ workDir: string;
8
+ systemPrompt: string;
9
+ userPrompt: string;
10
+ sessionId?: string;
11
+ timeoutSec: number;
12
+ /** The agent's own git worktree, when it does not work in the main repo. */
13
+ workspace?: AgentWorkspace;
14
+ /** Stop the wake-up (kill its process tree) when this fires. */
15
+ signal?: AbortSignal;
16
+ /** Where the full stdout/stderr of this invocation are written. */
17
+ logDir?: string;
18
+ }
19
+ export interface WakeResult {
20
+ ok: boolean;
21
+ text: string;
22
+ sessionId?: string;
23
+ outputTokens?: number;
24
+ exitCode: number | null;
25
+ timedOut: boolean;
26
+ error?: string;
27
+ /** Stopped because the run was cancelled, not because it failed. */
28
+ cancelled?: boolean;
29
+ }
30
+ export type Parsed = {
31
+ ok: boolean;
32
+ text: string;
33
+ sessionId?: string;
34
+ outputTokens?: number;
35
+ error?: string;
36
+ };
37
+ /** Reads a runtime's stdout line by line while it runs, keeping only what it needs. */
38
+ export interface StreamParser {
39
+ line(text: string): void;
40
+ finish(out: {
41
+ code: number | null;
42
+ stdoutTail: string;
43
+ stderrTail: string;
44
+ }): Parsed;
45
+ }
46
+ export interface Invocation {
47
+ cmd: string;
48
+ args: string[];
49
+ stdin: string;
50
+ cwd: string;
51
+ env?: NodeJS.ProcessEnv;
52
+ /** Interpret the finished process from its (tail of) output. Used when `stream` is absent. */
53
+ parse(out: {
54
+ stdout: string;
55
+ stderr: string;
56
+ code: number | null;
57
+ }): Parsed;
58
+ /** Preferred over `parse`: sees every stdout line as it is read, so memory stays bounded however much the runtime prints. */
59
+ stream?(): StreamParser;
60
+ }
61
+ export type Invoker = (input: WakeInput) => Promise<WakeResult>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,471 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import readline from "node:readline/promises";
5
+ import { Command } from "commander";
6
+ import { ConfigError, findProjectForCwd, flattenResolved, listProjects, resolveProject, resolveProjectWithSources } from "./config.js";
7
+ import { RESULT_FILE, runTeam } from "./dispatcher.js";
8
+ import { executeRunCleanup, planRunCleanup } from "./run-cleanup.js";
9
+ import { diagnoseProject, formatDoctor, preflightRuntimes } from "./doctor.js";
10
+ import { acquireProjectLock, forceUnlock, inspectProjectLock, lockHolderAlive } from "./project-lock.js";
11
+ import { loadRunState, newRunId, outcomeOf } from "./run-store.js";
12
+ import { exitCodeForOutcome } from "./schema.js";
13
+ import { absPath, assertName, projectPaths, resolveHome } from "./paths.js";
14
+ import { addProject, initHome, removeProject } from "./scaffold.js";
15
+ import { buildStatusReport, buildTaskListReport, formatMonitor, formatRunDetail, formatStatusWithLog, formatTaskList, latestUnfinishedRun, runIsAlive } from "./status.js";
16
+ import { prepareTask, readTaskFile } from "./task.js";
17
+ import { formatEnforcement, validateProject } from "./validate.js";
18
+ const program = new Command();
19
+ program
20
+ .name("agent-lyceum")
21
+ .description("Configure and run a team of Claude Code / Codex agents with scoped context and file mailboxes.")
22
+ .option("--home <dir>", "agent-lyceum home (default: $AGENT_LYCEUM_HOME or ~/agent-lyceum-config)");
23
+ const home = () => resolveHome(program.opts().home);
24
+ function fail(msg, code = 1) {
25
+ console.error(msg);
26
+ process.exit(code);
27
+ }
28
+ function loadProject(projectOpt) {
29
+ const h = home();
30
+ let name = projectOpt;
31
+ if (name)
32
+ assertName("project", name);
33
+ if (!name) {
34
+ name = findProjectForCwd(h, process.cwd());
35
+ if (!name) {
36
+ const known = listProjects(h);
37
+ fail(`No project given and the current directory is not inside a registered project.\n` +
38
+ (known.length
39
+ ? `Registered projects:\n${known.map((p) => ` ${p.name} -> ${p.dir}`).join("\n")}\nUse --project <name>.`
40
+ : `No projects registered. Use: agent-lyceum project add <name> --dir <repo>`));
41
+ }
42
+ }
43
+ if (!fs.existsSync(projectPaths(h, name).config))
44
+ fail(`Project "${name}" is not registered in ${h}.`);
45
+ return resolveProject(h, name);
46
+ }
47
+ program
48
+ .command("init")
49
+ .description("Create the agent-lyceum home with a global agent library")
50
+ .option("-y, --yes", "do not ask for confirmation of the home path")
51
+ .action(async (opts) => {
52
+ const h = home();
53
+ const explicit = program.opts().home || process.env.AGENT_LYCEUM_HOME;
54
+ if (!opts.yes && !explicit && !fs.existsSync(h) && process.stdin.isTTY) {
55
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
56
+ const ans = (await rl.question(`Create agent-lyceum home at ${h}? [Y/n] `)).trim().toLowerCase();
57
+ rl.close();
58
+ if (ans === "n" || ans === "no")
59
+ fail("Aborted. Set AGENT_LYCEUM_HOME or pass --home to choose another location.");
60
+ }
61
+ const r = initHome(h);
62
+ console.log(`Home: ${r.home}`);
63
+ for (const f of r.created)
64
+ console.log(` created ${path.relative(h, f)}`);
65
+ for (const f of r.skipped)
66
+ console.log(` kept ${path.relative(h, f)}`);
67
+ console.log(`\nNext: agent-lyceum project add <name> --dir <your-repo>`);
68
+ });
69
+ const project = program.command("project").description("Manage registered projects");
70
+ project
71
+ .command("add <name>")
72
+ .description("Register a project (config and context live in the home, not in the repo)")
73
+ .requiredOption("--dir <repo>", "the repo agents will work in")
74
+ .action((name, opts) => {
75
+ try {
76
+ const r = addProject(home(), name, opts.dir);
77
+ console.log(`Project "${name}" created: ${r.config}\nEdit it, then run: agent-lyceum validate --project ${name}`);
78
+ }
79
+ catch (e) {
80
+ fail(e.message);
81
+ }
82
+ });
83
+ project
84
+ .command("list")
85
+ .description("List registered projects")
86
+ .action(() => {
87
+ const list = listProjects(home());
88
+ if (!list.length)
89
+ console.log("No projects registered.");
90
+ for (const p of list)
91
+ console.log(`${p.name} -> ${p.dir}`);
92
+ });
93
+ project
94
+ .command("remove <name>")
95
+ .description("Unregister a project (keeps its context unless --purge)")
96
+ .option("--purge", "also delete all of the project's context")
97
+ .action((name, opts) => {
98
+ try {
99
+ assertName("project", name);
100
+ console.log(removeProject(home(), name, !!opts.purge));
101
+ }
102
+ catch (e) {
103
+ fail(e.message);
104
+ }
105
+ });
106
+ program
107
+ .command("validate")
108
+ .description("Validate the merged configuration and report enforcement levels")
109
+ .option("-p, --project <name>")
110
+ .option("--task-file <path>", "also check a task file")
111
+ .action((opts) => {
112
+ try {
113
+ const pr = loadProject(opts.project);
114
+ const res = validateProject(pr);
115
+ console.log(`Project: ${pr.name} (repo: ${pr.dir})\n`);
116
+ console.log(formatEnforcement(res.enforcement));
117
+ console.log("");
118
+ for (const i of res.issues)
119
+ console.log(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
120
+ if (opts.taskFile) {
121
+ try {
122
+ const t = readTaskFile(absPath(opts.taskFile, process.cwd()));
123
+ console.log(`task file OK (${Buffer.byteLength(t)} bytes)`);
124
+ }
125
+ catch (e) {
126
+ console.log(`ERROR ${e.message}`);
127
+ res.ok = false;
128
+ }
129
+ }
130
+ console.log(res.ok ? "\nValid." : "\nInvalid.");
131
+ process.exit(res.ok ? 0 : 1);
132
+ }
133
+ catch (e) {
134
+ fail(e instanceof ConfigError ? e.message : e.stack ?? String(e));
135
+ }
136
+ });
137
+ /** Ask the runtime CLIs what they support: refuse to start when something the team needs is explicitly missing, warn when unknown. */
138
+ async function preflight(pr) {
139
+ const r = await preflightRuntimes(pr);
140
+ for (const w of r.warnings)
141
+ console.error(`warn ${w}`);
142
+ if (r.errors.length)
143
+ fail(`${r.errors.map((e) => `ERROR ${e}`).join("\n")}\nFix the above (see \`agent-lyceum doctor\`).`);
144
+ }
145
+ /** Take the project's single-run lock, or exit with the reason. */
146
+ function takeLock(pr, runId) {
147
+ try {
148
+ return acquireProjectLock(pr.paths.root, runId);
149
+ }
150
+ catch (e) {
151
+ return fail(e.message);
152
+ }
153
+ }
154
+ /**
155
+ * Ctrl-C (or SIGTERM) stops the run cleanly: the first signal cancels it (agents are killed, unread mail is kept,
156
+ * the lock is released, exit code 130); a second one quits at once.
157
+ */
158
+ function cancelOnSignals() {
159
+ const ac = new AbortController();
160
+ let seen = 0;
161
+ const handler = (name) => () => {
162
+ if (++seen === 1) {
163
+ console.error(`\n${name}: stopping the run (running agents are being stopped; unread mail is kept). Press Ctrl-C again to quit at once.`);
164
+ ac.abort();
165
+ }
166
+ else
167
+ process.exit(130);
168
+ };
169
+ const onInt = handler("Interrupted");
170
+ const onTerm = handler("Terminated");
171
+ process.on("SIGINT", onInt);
172
+ process.on("SIGTERM", onTerm);
173
+ return { signal: ac.signal, dispose: () => (process.off("SIGINT", onInt), process.off("SIGTERM", onTerm)) };
174
+ }
175
+ function reportRun(summary, runDir) {
176
+ console.log(`\nRun ${summary.runId} ended: ${summary.endReason}, outcome: ${summary.outcome}, after ${summary.rounds} round(s). Logs: ${path.join(runDir, "log.jsonl")}`);
177
+ if (summary.outcomeNote)
178
+ console.log(`Note: ${summary.outcomeNote}`);
179
+ if (summary.verification)
180
+ console.log(`Verification (as reported by the lead, not checked by agent-lyceum): ${summary.verification}`);
181
+ if (summary.doneMessage) {
182
+ console.log(`\nLead's final message — ${summary.doneMessage.subject}\n\n${summary.doneMessage.body}`);
183
+ console.log(`\nResult saved to: ${path.join(runDir, RESULT_FILE)}`);
184
+ }
185
+ process.exit(exitCodeForOutcome(summary.outcome));
186
+ }
187
+ program
188
+ .command("run [task]")
189
+ .description('Start the dispatcher: give the task to the lead. Use "<task>" or --task-file <path>.')
190
+ .option("-p, --project <name>")
191
+ .option("--task-file <path>", "read the task from a file")
192
+ .action(async (task, opts) => {
193
+ try {
194
+ const pr = loadProject(opts.project);
195
+ const res = validateProject(pr);
196
+ for (const i of res.issues)
197
+ console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
198
+ if (!res.ok)
199
+ fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
200
+ await preflight(pr);
201
+ const runId = newRunId();
202
+ const runDir = path.join(pr.paths.runs, runId);
203
+ const lease = takeLock(pr, runId);
204
+ let summary;
205
+ const cancel = cancelOnSignals();
206
+ try {
207
+ const prepared = prepareTask({ text: task, file: opts.taskFile, cwd: process.cwd(), runDir });
208
+ console.log(`Project ${pr.name} — repo ${pr.dir}`);
209
+ console.log(`Task: ${prepared.source === "file" ? `file ${prepared.sourcePath}` : "text"} (${prepared.bytes} bytes${prepared.inline ? "" : ", passed by reference"})`);
210
+ summary = await runTeam({ project: pr, task: prepared, runDir, signal: cancel.signal });
211
+ }
212
+ finally {
213
+ cancel.dispose();
214
+ lease.release();
215
+ }
216
+ reportRun(summary, runDir);
217
+ }
218
+ catch (e) {
219
+ fail(e instanceof ConfigError ? e.message : e.message);
220
+ }
221
+ });
222
+ program
223
+ .command("resume [run-id]")
224
+ .description("Continue an interrupted (or failed) run: same run dir, sessions and round count; the task is not re-sent. Without an id, picks the newest run that is not done or running; with an id, continues that run.")
225
+ .option("-p, --project <name>")
226
+ .action(async (runId, opts) => {
227
+ try {
228
+ const pr = loadProject(opts.project);
229
+ const res = validateProject(pr);
230
+ for (const i of res.issues)
231
+ console.error(`${i.level === "error" ? "ERROR" : "warn "} ${i.message}`);
232
+ if (!res.ok)
233
+ fail("Configuration is invalid; fix the errors above (see `agent-lyceum validate`).");
234
+ await preflight(pr);
235
+ let found;
236
+ if (runId) {
237
+ assertName("run", runId);
238
+ const dir = path.join(pr.paths.runs, runId);
239
+ if (!fs.existsSync(path.join(dir, "state.json")))
240
+ fail(`Run "${runId}" not found in ${pr.paths.runs}.`);
241
+ found = { dir, state: loadRunState(dir) };
242
+ }
243
+ else {
244
+ found = latestUnfinishedRun(pr);
245
+ if (!found)
246
+ fail("No unfinished run to resume. Start one with: agent-lyceum run \"<task>\"");
247
+ }
248
+ const { dir, state } = found;
249
+ if (!state.end_reason && runIsAlive(state, pr))
250
+ fail(`Run ${state.run_id} is still running (pid ${state.pid}).`);
251
+ if (state.end_reason === "done") {
252
+ const result = path.join(dir, RESULT_FILE);
253
+ if (!fs.existsSync(result))
254
+ fail(`Run ${state.run_id} already finished (done); nothing to resume.`);
255
+ const o = outcomeOf(state);
256
+ console.log(`Run ${state.run_id} already finished (done); nothing to resume. Outcome: ${o.outcome}${o.verified ? "" : " (not verified: this run ended before outcomes were recorded)"}. Result: ${result}\n\n${fs.readFileSync(result, "utf8")}`);
257
+ process.exit(exitCodeForOutcome(o.outcome));
258
+ }
259
+ if (state.rounds >= pr.dispatcher.max_rounds)
260
+ fail(`Run ${state.run_id} used ${state.rounds}/${pr.dispatcher.max_rounds} rounds; raise dispatcher.max_rounds in project.yaml first.`);
261
+ const lease = takeLock(pr, state.run_id);
262
+ let summary;
263
+ const cancel = cancelOnSignals();
264
+ try {
265
+ console.log(`Project ${pr.name} — repo ${pr.dir}`);
266
+ console.log(`Resuming run ${state.run_id} (${state.end_reason ?? "interrupted"}) at round ${state.rounds}/${pr.dispatcher.max_rounds}: ${state.task_summary}`);
267
+ // Read the state again under the lock: another process may have changed it while we were checking.
268
+ summary = await runTeam({ project: pr, resume: loadRunState(dir), runDir: dir, signal: cancel.signal });
269
+ }
270
+ finally {
271
+ cancel.dispose();
272
+ lease.release();
273
+ }
274
+ reportRun(summary, dir);
275
+ }
276
+ catch (e) {
277
+ fail(e instanceof ConfigError ? e.message : e.message);
278
+ }
279
+ });
280
+ /** Color when writing to a terminal, unless NO_COLOR is set; FORCE_COLOR overrides. */
281
+ program
282
+ .command("clear <run-id>")
283
+ .description("Delete a task (run) by id: its run directory (state, log, result, mailboxes), its task memory and its agent worktrees, branches and snapshots. " +
284
+ "Long-term (global and project) memory is never touched. Refuses while the run is running, or while a worktree holds work that is not in the repo.")
285
+ .option("-p, --project <name>")
286
+ .option("--dry-run", "only list what would be deleted and what would be kept")
287
+ .option("--keep-worktrees", "keep the agents' worktrees and branches (and their snapshots), and still clear the run and its task memory")
288
+ .action((runId, opts) => {
289
+ try {
290
+ assertName("run", runId);
291
+ const pr = loadProject(opts.project);
292
+ const showPlan = (plan) => {
293
+ console.log(`Run ${plan.summary}`);
294
+ for (const i of plan.items)
295
+ console.log(` ${opts.dryRun ? "would delete" : "delete "} ${i.label}`);
296
+ for (const k of plan.keep)
297
+ console.log(` kept worktree of ${k.agent}: ${k.path} (branch ${k.branch})${k.files.length ? `, holding ${k.files.join(", ")}` : ""}`);
298
+ };
299
+ const refuse = (plan) => {
300
+ for (const r of plan.refusals)
301
+ console.error(`REFUSED ${r}`);
302
+ return fail("Nothing was deleted.");
303
+ };
304
+ if (opts.dryRun) {
305
+ const plan = planRunCleanup(pr, runId, { keepWorktrees: opts.keepWorktrees });
306
+ console.log("Dry run: nothing is deleted.");
307
+ showPlan(plan);
308
+ if (plan.refusals.length)
309
+ refuse(plan);
310
+ return;
311
+ }
312
+ const lease = takeLock(pr, runId);
313
+ try {
314
+ const plan = planRunCleanup(pr, runId, { keepWorktrees: opts.keepWorktrees });
315
+ if (plan.refusals.length)
316
+ refuse(plan);
317
+ showPlan(plan);
318
+ const report = executeRunCleanup(plan);
319
+ if (report.failed.length) {
320
+ for (const f of report.failed)
321
+ console.error(`FAILED ${f.item.label}: ${f.error}`);
322
+ fail("Cleanup stopped at the first failure; fix it and repeat the same command to finish.");
323
+ }
324
+ console.log(`Deleted run ${runId}: ${plan.summary.slice(runId.length + 2)}`);
325
+ }
326
+ finally {
327
+ lease.release();
328
+ }
329
+ }
330
+ catch (e) {
331
+ fail(e.message);
332
+ }
333
+ });
334
+ const configCmd = program.command("config").description("Inspect configuration");
335
+ configCmd
336
+ .command("show")
337
+ .description("Show the effective configuration of a project and where each value comes from")
338
+ .requiredOption("--resolved", "merge global library, project file and defaults (currently the only view)")
339
+ .option("-p, --project <name>")
340
+ .option("--json", "print as JSON")
341
+ .action((opts) => {
342
+ try {
343
+ const h = home();
344
+ const pr = loadProject(opts.project);
345
+ const { project, sources } = resolveProjectWithSources(h, pr.name);
346
+ const values = flattenResolved(project);
347
+ if (opts.json) {
348
+ console.log(JSON.stringify({ schema_version: 1, project: project.name, values, sources }, null, 2));
349
+ return;
350
+ }
351
+ const rel = (f) => path.relative(h, f) || f;
352
+ const where = (s) => ("default" in s ? "default" : "inferred_from" in s ? `inferred from ${s.inferred_from.key} (${rel(s.inferred_from.file)})` : rel(s.file));
353
+ const show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
354
+ const keys = Object.keys(sources).filter((k) => values[k] !== undefined);
355
+ const w = Math.max(...keys.map((k) => `${k} = ${show(values[k])}`.length));
356
+ for (const k of keys)
357
+ console.log(`${`${k} = ${show(values[k])}`.padEnd(w)} ${where(sources[k])}`);
358
+ }
359
+ catch (e) {
360
+ fail(e instanceof ConfigError ? e.message : e.message);
361
+ }
362
+ });
363
+ program
364
+ .command("doctor")
365
+ .description("Check the setup without running any agent: configuration, git, the project lock, and what each runtime CLI supports (from --version/--help).")
366
+ .option("-p, --project <name>")
367
+ .option("--json", "print the report as JSON")
368
+ .action(async (opts) => {
369
+ try {
370
+ const report = await diagnoseProject(loadProject(opts.project));
371
+ console.log(opts.json ? JSON.stringify({ schema_version: 1, ...report }, null, 2) : formatDoctor(report));
372
+ process.exit(report.ok ? 0 : 1);
373
+ }
374
+ catch (e) {
375
+ fail(e instanceof ConfigError ? e.message : e.message);
376
+ }
377
+ });
378
+ program
379
+ .command("unlock")
380
+ .description("Remove a project's run lock left behind by a crashed run. Shows the lock first; needs --force to remove it.")
381
+ .option("-p, --project <name>")
382
+ .option("--force", "remove the lock even though its owner cannot be confirmed dead")
383
+ .action((opts) => {
384
+ try {
385
+ const pr = loadProject(opts.project);
386
+ const info = inspectProjectLock(pr.paths.root);
387
+ if (!info && !fs.existsSync(path.join(pr.paths.root, "lock.json"))) {
388
+ console.log(`Project ${pr.name} has no run lock.`);
389
+ return;
390
+ }
391
+ if (info) {
392
+ console.log(`Lock held for run ${info.run_id} by pid ${info.pid} on ${info.hostname}, last heartbeat ${info.heartbeat_at}${lockHolderAlive(info) ? " (that process is still alive)" : " (that process is gone)"}.`);
393
+ }
394
+ else
395
+ console.log("The lock file exists but cannot be read.");
396
+ if (!opts.force)
397
+ fail("Not removed. Check that no run is active, then repeat with --force.");
398
+ forceUnlock(pr.paths.root);
399
+ console.log("Lock removed. Resume the run with: agent-lyceum resume");
400
+ }
401
+ catch (e) {
402
+ fail(e.message);
403
+ }
404
+ });
405
+ function useColor() {
406
+ if (process.env.NO_COLOR)
407
+ return false;
408
+ if (process.env.FORCE_COLOR)
409
+ return process.env.FORCE_COLOR !== "0";
410
+ return !!process.stdout.isTTY;
411
+ }
412
+ program
413
+ .command("status")
414
+ .description("Show agents, unread mail and the last run")
415
+ .option("-p, --project <name>")
416
+ .option("--task-list [project]", "list every task (run) of the project with its id and state")
417
+ .option("--task-id <id>", "show everything about one task (run): every wake and its result")
418
+ .option("--monitor", "keep the page open and refresh it (Ctrl-C to quit)")
419
+ .option("--interval <sec>", "refresh interval for --monitor", "2")
420
+ .option("--json", "print the report as JSON (no colour); works with --task-list and --task-id")
421
+ .action(async (opts) => {
422
+ try {
423
+ const pr = loadProject(typeof opts.taskList === "string" ? opts.taskList : opts.project);
424
+ if (opts.json) {
425
+ if (opts.monitor)
426
+ fail("--json cannot be combined with --monitor.");
427
+ if (opts.taskId)
428
+ assertName("run", opts.taskId);
429
+ const report = opts.taskList ? buildTaskListReport(pr) : buildStatusReport(pr, opts.taskId);
430
+ console.log(JSON.stringify(report, null, 2));
431
+ return;
432
+ }
433
+ if (opts.taskList) {
434
+ console.log(formatTaskList(pr, useColor()));
435
+ return;
436
+ }
437
+ if (opts.taskId) {
438
+ assertName("run", opts.taskId);
439
+ const dir = path.join(pr.paths.runs, opts.taskId);
440
+ if (!fs.existsSync(path.join(dir, "state.json")))
441
+ fail(`Run "${opts.taskId}" not found in ${pr.paths.runs}. List ids with: agent-lyceum status --task-list`);
442
+ console.log(formatRunDetail({ dir, state: loadRunState(dir) }, Date.now(), useColor(), pr));
443
+ return;
444
+ }
445
+ if (!opts.monitor) {
446
+ console.log(formatStatusWithLog(pr, Date.now(), useColor()));
447
+ return;
448
+ }
449
+ const sec = Number(opts.interval);
450
+ if (!(sec > 0))
451
+ fail("--interval must be a positive number of seconds.");
452
+ const draw = () => {
453
+ // Reload each tick so new mail, config edits and runs show up.
454
+ let text;
455
+ try {
456
+ text = formatMonitor(loadProject(opts.project), Date.now(), useColor());
457
+ }
458
+ catch (e) {
459
+ text = `(error: ${e.message})`;
460
+ }
461
+ process.stdout.write(`\x1b[2J\x1b[H${text}\n\nRefreshing every ${sec}s — ${new Date().toLocaleTimeString()} — Ctrl-C to quit\n`);
462
+ };
463
+ draw();
464
+ setInterval(draw, sec * 1000);
465
+ await new Promise(() => { });
466
+ }
467
+ catch (e) {
468
+ fail(e instanceof ConfigError ? e.message : e.message);
469
+ }
470
+ });
471
+ program.parseAsync().catch((e) => fail(e.message));
@@ -0,0 +1,115 @@
1
+ import { type ProjectPaths } from "./paths.js";
2
+ import type { AgentWorkspace } from "./worktree.js";
3
+ import { type DispatcherSettings, type Effort, type Runtime } from "./schema.js";
4
+ export declare class ConfigError extends Error {
5
+ }
6
+ export type Source = "global" | "project" | "default" | "model";
7
+ export interface ResolvedAgent {
8
+ name: string;
9
+ runtime?: Runtime;
10
+ model?: string;
11
+ effort?: Effort;
12
+ agentMd: string;
13
+ memory: {
14
+ global?: string;
15
+ project?: string;
16
+ task?: string;
17
+ };
18
+ resume: boolean;
19
+ canMessage: "all" | string[];
20
+ canEditAgentMd: boolean;
21
+ owns: string[];
22
+ /** Where each field's value came from (for `validate` output). */
23
+ sources: Record<string, Source>;
24
+ }
25
+ export interface ResolvedProject {
26
+ home: string;
27
+ name: string;
28
+ /** The repo agents work in. */
29
+ dir: string;
30
+ lead: string;
31
+ dispatcher: DispatcherSettings;
32
+ agents: Record<string, ResolvedAgent>;
33
+ paths: ProjectPaths;
34
+ /** Set once the project is bound to one run (see `bindRunProject`): which run, and where its mail lives. */
35
+ run?: {
36
+ id: string;
37
+ dir: string;
38
+ layout: "run" | "legacy";
39
+ };
40
+ /** Agents working in their own git worktree instead of `dir` (set by the dispatcher for the wake-ups in progress). */
41
+ workspaces?: Record<string, AgentWorkspace>;
42
+ }
43
+ export declare function loadGlobal(home: string): {
44
+ agents: Record<string, {
45
+ runtime?: "claude-code" | "codex" | undefined;
46
+ model?: string | undefined;
47
+ effort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;
48
+ agent_md?: string | undefined;
49
+ memory?: {
50
+ global?: string | undefined;
51
+ project?: string | undefined;
52
+ } | undefined;
53
+ resume?: boolean | undefined;
54
+ can_message?: string[] | "all" | undefined;
55
+ can_edit_agent_md?: boolean | undefined;
56
+ owns?: string[] | undefined;
57
+ }>;
58
+ };
59
+ export declare function loadProjectRaw(home: string, name: string): {
60
+ agents: Record<string, {
61
+ runtime?: "claude-code" | "codex" | undefined;
62
+ model?: string | undefined;
63
+ effort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | undefined;
64
+ agent_md?: string | undefined;
65
+ memory?: {
66
+ global?: string | undefined;
67
+ project?: string | undefined;
68
+ } | undefined;
69
+ resume?: boolean | undefined;
70
+ can_message?: string[] | "all" | undefined;
71
+ can_edit_agent_md?: boolean | undefined;
72
+ owns?: string[] | undefined;
73
+ }>;
74
+ dir: string;
75
+ team: {
76
+ lead: string;
77
+ };
78
+ dispatcher: {
79
+ strict?: boolean | undefined;
80
+ max_rounds?: number | undefined;
81
+ max_parallel?: number | undefined;
82
+ wake_timeout_sec?: number | undefined;
83
+ retry?: number | undefined;
84
+ workspace_mode?: "shared" | "auto" | "worktree" | undefined;
85
+ };
86
+ };
87
+ export declare function resolveProject(home: string, name: string): ResolvedProject;
88
+ /** Registered projects: name -> repo dir (best effort; skips broken configs). */
89
+ export declare function listProjects(home: string): {
90
+ name: string;
91
+ dir: string;
92
+ }[];
93
+ /** Pick the registered project whose dir is the longest prefix of `cwd`. */
94
+ export declare function findProjectForCwd(home: string, cwd: string): string | undefined;
95
+ /** Where one resolved value came from. */
96
+ export type SourceInfo = {
97
+ file: string;
98
+ key: string;
99
+ }
100
+ /** The runtime was not written down: it was recognised from this `model` setting. */
101
+ | {
102
+ inferred_from: {
103
+ file: string;
104
+ key: string;
105
+ };
106
+ } | {
107
+ default: true;
108
+ };
109
+ /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
110
+ export declare function resolveProjectWithSources(home: string, name: string): {
111
+ project: ResolvedProject;
112
+ sources: Record<string, SourceInfo>;
113
+ };
114
+ /** Every effective setting under the same keys as `resolveProjectWithSources` reports sources for. */
115
+ export declare function flattenResolved(project: ResolvedProject): Record<string, unknown>;