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.
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/README.zh-TW.md +133 -0
- package/dist/adapters/claude.d.ts +14 -0
- package/dist/adapters/claude.js +155 -0
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +109 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/types.d.ts +61 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +471 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.js +253 -0
- package/dist/dispatcher.d.ts +37 -0
- package/dist/dispatcher.js +467 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +151 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +100 -0
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +9 -0
- package/dist/guard.d.ts +20 -0
- package/dist/guard.js +72 -0
- package/dist/mailbox.d.ts +97 -0
- package/dist/mailbox.js +259 -0
- package/dist/message-store.d.ts +91 -0
- package/dist/message-store.js +225 -0
- package/dist/paths.d.ts +30 -0
- package/dist/paths.js +62 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +143 -0
- package/dist/process-runner.d.ts +21 -0
- package/dist/process-runner.js +165 -0
- package/dist/project-lock.d.ts +30 -0
- package/dist/project-lock.js +186 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +125 -0
- package/dist/run-cleanup.d.ts +51 -0
- package/dist/run-cleanup.js +120 -0
- package/dist/run-store.d.ts +111 -0
- package/dist/run-store.js +119 -0
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +126 -0
- package/dist/schema.d.ts +303 -0
- package/dist/schema.js +84 -0
- package/dist/status.d.ts +142 -0
- package/dist/status.js +399 -0
- package/dist/task.d.ts +24 -0
- package/dist/task.js +71 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +116 -0
- package/dist/worktree.d.ts +103 -0
- package/dist/worktree.js +395 -0
- package/package.json +58 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/** Most output kept in memory per stream; the rest only goes to the log files. */
|
|
5
|
+
export const TAIL_BYTES = 64 * 1024;
|
|
6
|
+
/** A stdout line longer than this is not offered to the adapter's parser (it is still logged). */
|
|
7
|
+
export const MAX_LINE_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
class Tail {
|
|
9
|
+
buf = Buffer.alloc(0);
|
|
10
|
+
push(chunk) {
|
|
11
|
+
this.buf = Buffer.concat([this.buf, chunk]);
|
|
12
|
+
if (this.buf.length > TAIL_BYTES * 2)
|
|
13
|
+
this.buf = this.buf.subarray(this.buf.length - TAIL_BYTES);
|
|
14
|
+
}
|
|
15
|
+
text() {
|
|
16
|
+
return (this.buf.length > TAIL_BYTES ? this.buf.subarray(this.buf.length - TAIL_BYTES) : this.buf).toString("utf8");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Fallback for adapters without `stream`: keep the head of stdout (bounded) for their `parse`. */
|
|
20
|
+
const FALLBACK_STDOUT_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
function endStream(s) {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const t = setTimeout(() => {
|
|
24
|
+
s.destroy();
|
|
25
|
+
resolve();
|
|
26
|
+
}, 2000);
|
|
27
|
+
t.unref();
|
|
28
|
+
s.end(() => {
|
|
29
|
+
clearTimeout(t);
|
|
30
|
+
resolve();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
|
|
36
|
+
* and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
|
|
37
|
+
* and the whole process tree is gone by then (SIGTERM, then SIGKILL after `killGraceMs`).
|
|
38
|
+
*/
|
|
39
|
+
export function runInvocation(inv, opts) {
|
|
40
|
+
const grace = opts.killGraceMs ?? 5000;
|
|
41
|
+
const settleWait = opts.settleWaitMs ?? 1000;
|
|
42
|
+
const cancelled = { ok: false, text: "", exitCode: null, timedOut: false, cancelled: true, error: "cancelled" };
|
|
43
|
+
if (opts.signal?.aborted)
|
|
44
|
+
return Promise.resolve(cancelled);
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
fs.mkdirSync(opts.logDir, { recursive: true });
|
|
47
|
+
const outLog = fs.createWriteStream(path.join(opts.logDir, "stdout.log"));
|
|
48
|
+
const errLog = fs.createWriteStream(path.join(opts.logDir, "stderr.log"));
|
|
49
|
+
outLog.on("error", () => { });
|
|
50
|
+
errLog.on("error", () => { });
|
|
51
|
+
const child = spawn(inv.cmd, inv.args, { cwd: inv.cwd, env: inv.env, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
52
|
+
const stdoutTail = new Tail();
|
|
53
|
+
const stderrTail = new Tail();
|
|
54
|
+
const parser = inv.stream?.();
|
|
55
|
+
let fallback = Buffer.alloc(0);
|
|
56
|
+
let exitCode;
|
|
57
|
+
let reason;
|
|
58
|
+
let settled = false;
|
|
59
|
+
const timers = [];
|
|
60
|
+
const killGroup = (sig) => {
|
|
61
|
+
if (child.pid === undefined)
|
|
62
|
+
return;
|
|
63
|
+
try {
|
|
64
|
+
if (process.platform === "win32")
|
|
65
|
+
child.kill(sig);
|
|
66
|
+
else
|
|
67
|
+
process.kill(-child.pid, sig);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* nothing left to signal */
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const finish = (spawnError) => {
|
|
74
|
+
if (settled)
|
|
75
|
+
return;
|
|
76
|
+
settled = true;
|
|
77
|
+
for (const t of timers)
|
|
78
|
+
clearTimeout(t);
|
|
79
|
+
opts.signal?.removeEventListener("abort", onAbort);
|
|
80
|
+
killGroup("SIGKILL"); // whatever of the tree is still around
|
|
81
|
+
child.stdout?.destroy();
|
|
82
|
+
child.stderr?.destroy();
|
|
83
|
+
void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
|
|
84
|
+
if (spawnError) {
|
|
85
|
+
const hint = spawnError.code === "ENOENT" ? ` ("${inv.cmd}" not found on PATH)` : "";
|
|
86
|
+
return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}` });
|
|
87
|
+
}
|
|
88
|
+
if (reason === "cancel")
|
|
89
|
+
return resolve({ ...cancelled, exitCode: exitCode ?? null });
|
|
90
|
+
if (reason === "timeout")
|
|
91
|
+
return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s` });
|
|
92
|
+
const parsed = parser
|
|
93
|
+
? parser.finish({ code: exitCode ?? null, stdoutTail: stdoutTail.text(), stderrTail: stderrTail.text() })
|
|
94
|
+
: inv.parse({ stdout: fallback.length ? fallback.toString("utf8") : stdoutTail.text(), stderr: stderrTail.text(), code: exitCode ?? null });
|
|
95
|
+
resolve({ ...parsed, exitCode: exitCode ?? null, timedOut: false });
|
|
96
|
+
});
|
|
97
|
+
};
|
|
98
|
+
/** Stop the whole tree, then give up on the pipes shortly after, whatever still holds them. */
|
|
99
|
+
const stop = (why) => {
|
|
100
|
+
if (settled || reason)
|
|
101
|
+
return;
|
|
102
|
+
reason = why;
|
|
103
|
+
killGroup("SIGTERM");
|
|
104
|
+
timers.push(setTimeout(() => killGroup("SIGKILL"), grace));
|
|
105
|
+
timers.push(setTimeout(() => finish(), grace + settleWait));
|
|
106
|
+
};
|
|
107
|
+
function onAbort() {
|
|
108
|
+
stop("cancel");
|
|
109
|
+
}
|
|
110
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
111
|
+
timers.push(setTimeout(() => stop("timeout"), opts.timeoutSec * 1000));
|
|
112
|
+
// stdout: log in full with backpressure, keep a tail, and give each line to the adapter
|
|
113
|
+
let partial = "";
|
|
114
|
+
let skipping = false;
|
|
115
|
+
const onLine = (l) => parser?.line(l);
|
|
116
|
+
child.stdout.on("data", (chunk) => {
|
|
117
|
+
if (!outLog.write(chunk)) {
|
|
118
|
+
child.stdout.pause();
|
|
119
|
+
outLog.once("drain", () => child.stdout.resume());
|
|
120
|
+
}
|
|
121
|
+
stdoutTail.push(chunk);
|
|
122
|
+
if (!parser && fallback.length < FALLBACK_STDOUT_BYTES)
|
|
123
|
+
fallback = Buffer.concat([fallback, chunk]);
|
|
124
|
+
if (!parser)
|
|
125
|
+
return;
|
|
126
|
+
let text = partial + chunk.toString("utf8");
|
|
127
|
+
partial = "";
|
|
128
|
+
let nl;
|
|
129
|
+
while ((nl = text.indexOf("\n")) >= 0) {
|
|
130
|
+
const line = text.slice(0, nl).replace(/\r$/, "");
|
|
131
|
+
text = text.slice(nl + 1);
|
|
132
|
+
if (skipping)
|
|
133
|
+
skipping = false;
|
|
134
|
+
else if (line)
|
|
135
|
+
onLine(line);
|
|
136
|
+
}
|
|
137
|
+
if (skipping || text.length > MAX_LINE_BYTES) {
|
|
138
|
+
skipping = true; // drop the rest of an enormous line
|
|
139
|
+
}
|
|
140
|
+
else
|
|
141
|
+
partial = text;
|
|
142
|
+
});
|
|
143
|
+
child.stderr.on("data", (chunk) => {
|
|
144
|
+
if (!errLog.write(chunk)) {
|
|
145
|
+
child.stderr.pause();
|
|
146
|
+
errLog.once("drain", () => child.stderr.resume());
|
|
147
|
+
}
|
|
148
|
+
stderrTail.push(chunk);
|
|
149
|
+
});
|
|
150
|
+
child.on("error", (e) => finish(e));
|
|
151
|
+
child.on("exit", (code) => {
|
|
152
|
+
exitCode = code;
|
|
153
|
+
// The runtime is gone. Normally its pipes close at once; if a descendant still holds them, stop waiting soon.
|
|
154
|
+
timers.push(setTimeout(() => finish(), settleWait));
|
|
155
|
+
});
|
|
156
|
+
child.on("close", () => {
|
|
157
|
+
if (partial && parser && !skipping)
|
|
158
|
+
onLine(partial.replace(/\r$/, ""));
|
|
159
|
+
partial = "";
|
|
160
|
+
finish();
|
|
161
|
+
});
|
|
162
|
+
child.stdin.on("error", () => { });
|
|
163
|
+
child.stdin.end(inv.stdin);
|
|
164
|
+
});
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare const LOCK_FILE = "lock.json";
|
|
2
|
+
export interface LockInfo {
|
|
3
|
+
token: string;
|
|
4
|
+
hostname: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
/** `ps` start time of `pid`, to tell the holder from a different process that reused the pid. */
|
|
7
|
+
pid_started?: string;
|
|
8
|
+
run_id: string;
|
|
9
|
+
acquired_at: string;
|
|
10
|
+
heartbeat_at: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ProjectLease {
|
|
13
|
+
readonly info: LockInfo;
|
|
14
|
+
/** True once another process replaced our lock file; the run should stop writing. */
|
|
15
|
+
readonly lost: boolean;
|
|
16
|
+
release(): void;
|
|
17
|
+
}
|
|
18
|
+
export interface LockOptions {
|
|
19
|
+
heartbeatMs?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The lock of a project (`root` = the project's home directory), if one is held and readable. */
|
|
22
|
+
export declare function inspectProjectLock(root: string): LockInfo | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
25
|
+
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
26
|
+
*/
|
|
27
|
+
export declare function lockHolderAlive(info: LockInfo): boolean;
|
|
28
|
+
export declare function acquireProjectLock(root: string, runId: string, opts?: LockOptions): ProjectLease;
|
|
29
|
+
/** Remove the lock regardless of owner (the `unlock --force` escape hatch). Returns what was there, if readable. */
|
|
30
|
+
export declare function forceUnlock(root: string): LockInfo | undefined;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { atomicWrite } from "./fs-util.js";
|
|
7
|
+
export const LOCK_FILE = "lock.json";
|
|
8
|
+
const RECLAIM_FILE = "lock.json.reclaim";
|
|
9
|
+
const HEARTBEAT_MS = 5000;
|
|
10
|
+
function pidStart(pid) {
|
|
11
|
+
const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
|
|
12
|
+
const out = r.status === 0 ? r.stdout.trim() : "";
|
|
13
|
+
return out || undefined;
|
|
14
|
+
}
|
|
15
|
+
function readLock(root) {
|
|
16
|
+
let text;
|
|
17
|
+
try {
|
|
18
|
+
text = fs.readFileSync(path.join(root, LOCK_FILE), "utf8");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return { kind: "none" };
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
const j = JSON.parse(text);
|
|
25
|
+
if (j && typeof j.token === "string" && typeof j.pid === "number" && typeof j.run_id === "string")
|
|
26
|
+
return { kind: "ok", info: j };
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
/* fall through */
|
|
30
|
+
}
|
|
31
|
+
return { kind: "unreadable" };
|
|
32
|
+
}
|
|
33
|
+
/** The lock of a project (`root` = the project's home directory), if one is held and readable. */
|
|
34
|
+
export function inspectProjectLock(root) {
|
|
35
|
+
const r = readLock(root);
|
|
36
|
+
return r.kind === "ok" ? r.info : undefined;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Is the process that wrote this lock still the same running process? Judged by pid and its start time,
|
|
40
|
+
* never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
|
|
41
|
+
*/
|
|
42
|
+
export function lockHolderAlive(info) {
|
|
43
|
+
try {
|
|
44
|
+
process.kill(info.pid, 0);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
if (e.code !== "EPERM")
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (info.pid_started) {
|
|
51
|
+
const now = pidStart(info.pid);
|
|
52
|
+
if (now && now !== info.pid_started)
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
function heldError(info) {
|
|
58
|
+
const late = Math.round((Date.now() - Date.parse(info.heartbeat_at)) / 1000);
|
|
59
|
+
return new Error(`This project is still running: run ${info.run_id} (pid ${info.pid}, heartbeat ${Number.isFinite(late) ? late : "?"}s ago). ` +
|
|
60
|
+
`Only one run per project at a time; wait for it to end, or stop it first.`);
|
|
61
|
+
}
|
|
62
|
+
function unreadableError(root) {
|
|
63
|
+
return new Error(`The project lock ${path.join(root, LOCK_FILE)} cannot be read or has no known owner. Check that no run is active, then run \`agent-lyceum unlock --force\`.`);
|
|
64
|
+
}
|
|
65
|
+
/** Create the lock file only if absent: write a private temp file, then hard-link it into place (atomic, never half-written). */
|
|
66
|
+
function tryCreate(root, info) {
|
|
67
|
+
const tmp = path.join(root, `.${LOCK_FILE}.${info.token}.tmp`);
|
|
68
|
+
fs.writeFileSync(tmp, JSON.stringify(info, null, 2));
|
|
69
|
+
try {
|
|
70
|
+
fs.linkSync(tmp, path.join(root, LOCK_FILE));
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e.code === "EEXIST")
|
|
75
|
+
return false;
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
fs.rmSync(tmp, { force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Replace a lock whose holder is gone. Reclaimers take a short mutex so two of them cannot delete each other's fresh lock. */
|
|
83
|
+
function reclaim(root, info, stale) {
|
|
84
|
+
const mutex = path.join(root, RECLAIM_FILE);
|
|
85
|
+
try {
|
|
86
|
+
if (Date.now() - fs.statSync(mutex).mtimeMs > 10_000)
|
|
87
|
+
fs.rmSync(mutex, { force: true });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* no mutex yet */
|
|
91
|
+
}
|
|
92
|
+
let fd;
|
|
93
|
+
try {
|
|
94
|
+
fd = fs.openSync(mutex, "wx");
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const again = readLock(root);
|
|
101
|
+
if (again.kind !== "ok" || again.info.token !== stale.token)
|
|
102
|
+
return false;
|
|
103
|
+
fs.rmSync(path.join(root, LOCK_FILE), { force: true });
|
|
104
|
+
return tryCreate(root, info);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
fs.closeSync(fd);
|
|
108
|
+
fs.rmSync(mutex, { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function acquireProjectLock(root, runId, opts = {}) {
|
|
112
|
+
fs.mkdirSync(root, { recursive: true });
|
|
113
|
+
const now = new Date().toISOString();
|
|
114
|
+
const info = {
|
|
115
|
+
token: crypto.randomBytes(16).toString("hex"),
|
|
116
|
+
hostname: os.hostname(),
|
|
117
|
+
pid: process.pid,
|
|
118
|
+
pid_started: pidStart(process.pid),
|
|
119
|
+
run_id: runId,
|
|
120
|
+
acquired_at: now,
|
|
121
|
+
heartbeat_at: now,
|
|
122
|
+
};
|
|
123
|
+
for (let attempt = 0;; attempt++) {
|
|
124
|
+
if (tryCreate(root, info))
|
|
125
|
+
break;
|
|
126
|
+
const cur = readLock(root);
|
|
127
|
+
if (cur.kind === "unreadable")
|
|
128
|
+
throw unreadableError(root);
|
|
129
|
+
if (cur.kind === "none")
|
|
130
|
+
continue; // released between our two calls
|
|
131
|
+
if (lockHolderAlive(cur.info))
|
|
132
|
+
throw heldError(cur.info);
|
|
133
|
+
if (reclaim(root, info, cur.info))
|
|
134
|
+
break;
|
|
135
|
+
if (attempt >= 5)
|
|
136
|
+
throw new Error(`Could not take the project lock ${path.join(root, LOCK_FILE)}; another process is reclaiming it. Try again.`);
|
|
137
|
+
}
|
|
138
|
+
let released = false;
|
|
139
|
+
let lost = false;
|
|
140
|
+
const file = path.join(root, LOCK_FILE);
|
|
141
|
+
const mine = () => {
|
|
142
|
+
const r = readLock(root);
|
|
143
|
+
return r.kind === "ok" && r.info.token === info.token;
|
|
144
|
+
};
|
|
145
|
+
const timer = setInterval(() => {
|
|
146
|
+
if (released)
|
|
147
|
+
return;
|
|
148
|
+
if (!mine()) {
|
|
149
|
+
lost = true;
|
|
150
|
+
clearInterval(timer);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
info.heartbeat_at = new Date().toISOString();
|
|
154
|
+
try {
|
|
155
|
+
atomicWrite(file, JSON.stringify(info, null, 2));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
/* try again next tick */
|
|
159
|
+
}
|
|
160
|
+
}, opts.heartbeatMs ?? HEARTBEAT_MS);
|
|
161
|
+
timer.unref();
|
|
162
|
+
const release = () => {
|
|
163
|
+
if (released)
|
|
164
|
+
return;
|
|
165
|
+
released = true;
|
|
166
|
+
clearInterval(timer);
|
|
167
|
+
process.off("exit", release);
|
|
168
|
+
if (mine())
|
|
169
|
+
fs.rmSync(file, { force: true });
|
|
170
|
+
};
|
|
171
|
+
process.on("exit", release);
|
|
172
|
+
return {
|
|
173
|
+
info,
|
|
174
|
+
get lost() {
|
|
175
|
+
return lost;
|
|
176
|
+
},
|
|
177
|
+
release,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** Remove the lock regardless of owner (the `unlock --force` escape hatch). Returns what was there, if readable. */
|
|
181
|
+
export function forceUnlock(root) {
|
|
182
|
+
const info = inspectProjectLock(root);
|
|
183
|
+
fs.rmSync(path.join(root, LOCK_FILE), { force: true });
|
|
184
|
+
fs.rmSync(path.join(root, RECLAIM_FILE), { force: true });
|
|
185
|
+
return info;
|
|
186
|
+
}
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ResolvedAgent, ResolvedProject } from "./config.js";
|
|
2
|
+
import type { Message } from "./mailbox.js";
|
|
3
|
+
export declare const COMMON_LIMIT: number;
|
|
4
|
+
export declare const MEMORY_INDEX_LIMIT: number;
|
|
5
|
+
export declare const LEAD_BATCH_LIMIT = 5;
|
|
6
|
+
export declare function readPersona(agent: ResolvedAgent): string;
|
|
7
|
+
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
8
|
+
export declare function buildSystemPrompt(project: ResolvedProject, agent: ResolvedAgent): string;
|
|
9
|
+
/** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
|
|
10
|
+
export declare function pickMessages(project: ResolvedProject, agent: ResolvedAgent, queue: Message[]): Message[];
|
|
11
|
+
export declare function buildUserPrompt(project: ResolvedProject, agent: ResolvedAgent, unread: Message[], handleCount?: number): string;
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { REQUIRED_SECTIONS } from "./format.js";
|
|
4
|
+
import { commonFile, memoryDirs, outboxDir, repoDirFor } from "./policy.js";
|
|
5
|
+
function memoryLabel(agent, dir) {
|
|
6
|
+
if (dir === agent.memory.task)
|
|
7
|
+
return "task (this task only, isolated)";
|
|
8
|
+
return dir === agent.memory.global ? "global (all projects)" : "project (all tasks of this project)";
|
|
9
|
+
}
|
|
10
|
+
export const COMMON_LIMIT = 8 * 1024;
|
|
11
|
+
export const MEMORY_INDEX_LIMIT = 4 * 1024;
|
|
12
|
+
export const LEAD_BATCH_LIMIT = 5;
|
|
13
|
+
const BATCHABLE = new Set(["reply", "failure"]);
|
|
14
|
+
function readCapped(file, limit) {
|
|
15
|
+
if (!fs.existsSync(file))
|
|
16
|
+
return undefined;
|
|
17
|
+
const buf = fs.readFileSync(file);
|
|
18
|
+
if (buf.length <= limit)
|
|
19
|
+
return { text: buf.toString("utf8"), truncated: false };
|
|
20
|
+
return { text: buf.subarray(0, limit).toString("utf8"), truncated: true };
|
|
21
|
+
}
|
|
22
|
+
export function readPersona(agent) {
|
|
23
|
+
return fs.readFileSync(agent.agentMd, "utf8").trim();
|
|
24
|
+
}
|
|
25
|
+
/** Everything that goes to the runtime's system prompt: the persona plus the team protocol. */
|
|
26
|
+
export function buildSystemPrompt(project, agent) {
|
|
27
|
+
const outbox = outboxDir(project, agent.name);
|
|
28
|
+
const mates = Object.keys(project.agents).filter((n) => n !== agent.name);
|
|
29
|
+
const recipients = agent.canMessage === "all" ? mates : agent.canMessage.filter((n) => project.agents[n] && n !== agent.name);
|
|
30
|
+
const isLead = agent.name === project.lead;
|
|
31
|
+
const lines = [];
|
|
32
|
+
lines.push(readPersona(agent), "", "---", "", "# Team protocol (injected by agent-lyceum)", "");
|
|
33
|
+
lines.push(`You are **${agent.name}**, a member of the team "${project.name}". You work in the repository \`${repoDirFor(project, agent.name)}\`.`, ...(project.workspaces?.[agent.name]
|
|
34
|
+
? [
|
|
35
|
+
`That directory is your own isolated git worktree (branch \`${project.workspaces[agent.name].branch}\`): teammates cannot see your changes while you work, and you cannot see theirs. Do not commit, push, switch branches or touch any other checkout; the dispatcher collects your changes from this directory after you finish.`,
|
|
36
|
+
]
|
|
37
|
+
: []), `The team lead is **${project.lead}**${isLead ? " (that is you)" : ""}. Teammates: ${mates.join(", ") || "(none)"}.`, "", "## Sending mail", "You cannot talk to teammates directly. Mail you receive is shown in your prompt. To send mail, create a Markdown file", `(any name ending in \`.md\`) directly inside \`${outbox}\` with this exact shape:`, "", "```", "---", "to: <agent-name>", "type: reply # task | reply" + (isLead ? " | done" : ""), "subject: <short subject>", "reply_to: <message id> # optional", "---", "", "<body>", "```", "", `You may send to: ${recipients.join(", ") || "(nobody)"}.`, isLead
|
|
38
|
+
? `When the whole job is finished, send a message with \`type: done\` (no \`to\` needed). That ends the run. A summary in your reply text is never delivered and does not end the run: after the last teammate reply is verified you MUST send \`done\` (or a \`task\` for the remaining work), or the run stops as "idle" with no final report.`
|
|
39
|
+
: `Report results to the lead by mail; the lead decides what happens next.`, isLead
|
|
40
|
+
? "Each wake-up handles one message, except that several `reply`/`failure` messages waiting together are handed to you in one wake-up: weigh them together, then decide the next step once."
|
|
41
|
+
: "Each wake-up handles one message. Do the work it asks, then send the replies that are needed, then stop.", "", "## Message format", "The body of every `task` and `reply` must contain these `##` headings, exactly as written (write `None` under a heading if there is nothing to say).", "Mail missing a heading is still delivered, but is flagged to the recipient.", "", `- \`task\`: ${REQUIRED_SECTIONS.task.map((h) => `\`## ${h}\``).join(", ")} (Upstream = id of the mail this task derives from, or \`None\`).`, `- \`reply\`: ${REQUIRED_SECTIONS.reply.map((h) => `\`## ${h}\``).join(", ")}.`, isLead
|
|
42
|
+
? "- `done`: your final report to the user (shown when the run ends, saved as `result.md`). Its frontmatter must contain `outcome: completed|partial|blocked|failed`, and the body the headings `## Result` (the actual deliverable or conclusion, not just \"done\"), `## Files` (paths created or changed), `## Verification` (what you ran or checked, and what it showed) and `## Not done` (anything skipped or unverified, or `None`). Only `completed` means success, and it needs all four headings and every checklist step ticked; if work remains or is blocked, say `partial`/`blocked`/`failed` (those need only `## Result` and `## Not done`). A `done` that breaks this is sent back to you and does not end the run."
|
|
43
|
+
: "- `done` has no required headings.", ...(isLead
|
|
44
|
+
? [
|
|
45
|
+
"",
|
|
46
|
+
"## Progress tracking (optional, shown in `agent-lyceum status`)",
|
|
47
|
+
"Put a `## Steps` heading with a checklist (`- [ ] step`, `- [x] finished step`) in the mail you send. Each time you send mail, re-list the full",
|
|
48
|
+
"checklist with finished steps ticked; the latest one you send is what `status` reports as task progress.",
|
|
49
|
+
"Your final `done` mail must also include the full `## Steps` checklist. Tick only steps that were actually done and verified; leave undone ones",
|
|
50
|
+
"(e.g. build, app start, commit) unticked and say so in the mail instead of reporting them as finished.",
|
|
51
|
+
]
|
|
52
|
+
: []), "", "## Memory");
|
|
53
|
+
const mems = memoryDirs(agent);
|
|
54
|
+
if (mems.length === 0)
|
|
55
|
+
lines.push("You have no long-term memory directory.");
|
|
56
|
+
for (const m of mems) {
|
|
57
|
+
const label = memoryLabel(agent, m);
|
|
58
|
+
lines.push(`- ${label}: \`${m}\``);
|
|
59
|
+
}
|
|
60
|
+
if (mems.length) {
|
|
61
|
+
lines.push("Keep a `MEMORY.md` index in each memory directory (one line per entry, pointing at a file). Its contents are shown in your prompt", "on every wake-up; read other memory files on demand; you can write only inside these directories.", "Put anything specific to the current task (decisions, progress, findings, branch names) in the **task** memory: it is private to this task and starts empty for every new task.", "Use the global/project memory only for durable lessons that stay true across tasks; never record task-specific details there.");
|
|
62
|
+
}
|
|
63
|
+
lines.push("", "## Rules");
|
|
64
|
+
lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
|
|
65
|
+
(isLead || agent.canEditAgentMd ? " unless you need to as lead (changes are logged)." : "."), "- Never touch another agent's memory, inbox or outbox, or the team's config files.", `- The shared team context is in \`${commonFile(project)}\`${isLead ? " (you maintain it; keep it short)" : " (read-only)"}.`);
|
|
66
|
+
if (project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead) {
|
|
67
|
+
lines.push(`- You own these repo paths and must only change files there: ${agent.owns.join(", ")}.`);
|
|
68
|
+
}
|
|
69
|
+
return lines.join("\n");
|
|
70
|
+
}
|
|
71
|
+
/** Messages one wake-up handles: the oldest one, or for the lead a run of `reply`/`failure` mail (up to LEAD_BATCH_LIMIT) so it decides once on all of it. */
|
|
72
|
+
export function pickMessages(project, agent, queue) {
|
|
73
|
+
const sorted = [...queue].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
|
|
74
|
+
if (agent.name !== project.lead || !BATCHABLE.has(sorted[0]?.meta.type))
|
|
75
|
+
return sorted.slice(0, 1);
|
|
76
|
+
const run = [];
|
|
77
|
+
for (const m of sorted) {
|
|
78
|
+
if (!BATCHABLE.has(m.meta.type) || run.length >= LEAD_BATCH_LIMIT)
|
|
79
|
+
break;
|
|
80
|
+
run.push(m);
|
|
81
|
+
}
|
|
82
|
+
return run;
|
|
83
|
+
}
|
|
84
|
+
export function buildUserPrompt(project, agent, unread, handleCount = 1) {
|
|
85
|
+
const parts = [];
|
|
86
|
+
const common = readCapped(commonFile(project), COMMON_LIMIT);
|
|
87
|
+
parts.push("# Shared team context (COMMON.md)");
|
|
88
|
+
if (!common)
|
|
89
|
+
parts.push("(empty)");
|
|
90
|
+
else {
|
|
91
|
+
parts.push(common.text.trim());
|
|
92
|
+
if (common.truncated) {
|
|
93
|
+
parts.push(`\n[COMMON.md was truncated at ${COMMON_LIMIT} bytes — the lead should condense it.]`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const dir of memoryDirs(agent)) {
|
|
97
|
+
const idx = readCapped(path.join(dir, "MEMORY.md"), MEMORY_INDEX_LIMIT);
|
|
98
|
+
const label = memoryLabel(agent, dir).split(" ")[0];
|
|
99
|
+
parts.push("", `# Your ${label} memory index (${dir}/MEMORY.md)`);
|
|
100
|
+
if (!idx)
|
|
101
|
+
parts.push("(no MEMORY.md yet — create one when you have something worth keeping)");
|
|
102
|
+
else {
|
|
103
|
+
parts.push(idx.text.trim());
|
|
104
|
+
if (idx.truncated)
|
|
105
|
+
parts.push(`\n[index truncated at ${MEMORY_INDEX_LIMIT} bytes — trim it]`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const sorted = [...unread].sort((a, b) => path.basename(a.file).localeCompare(path.basename(b.file)));
|
|
109
|
+
const current = sorted.slice(0, handleCount);
|
|
110
|
+
const queued = sorted.slice(handleCount);
|
|
111
|
+
parts.push("", current.length > 1 ? `# Messages to handle now (${current.length}, oldest first — decide on them together)` : "# Message to handle now");
|
|
112
|
+
if (current.length === 0)
|
|
113
|
+
parts.push("(no message)");
|
|
114
|
+
current.forEach((m, i) => {
|
|
115
|
+
if (current.length > 1)
|
|
116
|
+
parts.push("", `## Message ${i + 1} of ${current.length}`);
|
|
117
|
+
parts.push(`id: ${m.meta.id}`, `from: ${m.meta.from}`, `type: ${m.meta.type}`, `thread: ${m.meta.thread}`, `subject: ${m.meta.subject}`, "", m.body);
|
|
118
|
+
});
|
|
119
|
+
if (queued.length) {
|
|
120
|
+
parts.push("", "# Queued messages (not handled in this wake-up; each gets its own wake-up later, titles only)");
|
|
121
|
+
for (const m of queued)
|
|
122
|
+
parts.push(`- from ${m.meta.from} [${m.meta.type}] "${m.meta.subject}" -> ${m.file}`);
|
|
123
|
+
}
|
|
124
|
+
return parts.join("\n");
|
|
125
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ResolvedProject } from "./config.js";
|
|
2
|
+
export interface CleanupItem {
|
|
3
|
+
kind: "workspace" | "refs" | "task-memory" | "run";
|
|
4
|
+
/** One line for people. */
|
|
5
|
+
label: string;
|
|
6
|
+
path?: string;
|
|
7
|
+
agent?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CleanupPlan {
|
|
10
|
+
project: ResolvedProject;
|
|
11
|
+
runId: string;
|
|
12
|
+
runDir: string;
|
|
13
|
+
keepWorktrees: boolean;
|
|
14
|
+
/** What will be removed, in this order (the run itself last, so an interrupted clear can be found and repeated). */
|
|
15
|
+
items: CleanupItem[];
|
|
16
|
+
/** Reasons not to clear at all. */
|
|
17
|
+
refusals: string[];
|
|
18
|
+
/** Work that exists only in a worktree: kept, never deleted. */
|
|
19
|
+
keep: {
|
|
20
|
+
agent: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
path: string;
|
|
23
|
+
files: string[];
|
|
24
|
+
}[];
|
|
25
|
+
/** Short description of the run, for messages. */
|
|
26
|
+
summary: string;
|
|
27
|
+
}
|
|
28
|
+
export interface CleanupReport {
|
|
29
|
+
done: CleanupItem[];
|
|
30
|
+
failed: {
|
|
31
|
+
item: CleanupItem;
|
|
32
|
+
error: string;
|
|
33
|
+
}[];
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* What clearing a run would do. Deletes nothing. Refuses (see `refusals`) while the run is running, or while
|
|
37
|
+
* a worktree holds work that is not in the repo, unless `keepWorktrees` keeps the worktrees and their branches.
|
|
38
|
+
*/
|
|
39
|
+
export declare function planRunCleanup(project: ResolvedProject, runId: string, opts?: {
|
|
40
|
+
keepWorktrees?: boolean;
|
|
41
|
+
}): CleanupPlan;
|
|
42
|
+
export interface ExecuteHooks {
|
|
43
|
+
/** Test seam: called after each item is done and recorded. */
|
|
44
|
+
afterItem?: (item: CleanupItem) => void;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Carry out a plan, step by step. Progress is written to a journal in the project home (outside the run being
|
|
48
|
+
* deleted), so an interrupted clear can simply be repeated. Stops at the first step that fails, leaving the run
|
|
49
|
+
* directory in place for the retry.
|
|
50
|
+
*/
|
|
51
|
+
export declare function executeRunCleanup(plan: CleanupPlan, hooks?: ExecuteHooks): CleanupReport;
|