agent-lyceum 0.17.3 → 0.19.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 +4 -3
- package/README.zh-TW.md +4 -3
- package/dist/adapters/claude.d.ts +12 -0
- package/dist/adapters/claude.js +39 -8
- package/dist/adapters/codex.d.ts +7 -0
- package/dist/adapters/codex.js +42 -1
- package/dist/adapters/types.d.ts +5 -0
- package/dist/call.d.ts +5 -0
- package/dist/call.js +35 -13
- package/dist/cli.js +17 -9
- package/dist/config.d.ts +36 -7
- package/dist/config.js +75 -8
- package/dist/demo.js +0 -1
- package/dist/dispatcher.d.ts +2 -1
- package/dist/dispatcher.js +3 -1
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +25 -17
- package/dist/fallback.d.ts +111 -0
- package/dist/fallback.js +157 -0
- package/dist/paths.d.ts +0 -2
- package/dist/paths.js +0 -11
- package/dist/policy.d.ts +2 -6
- package/dist/policy.js +6 -10
- package/dist/process-runner.js +4 -2
- package/dist/project-lock.d.ts +10 -2
- package/dist/project-lock.js +23 -12
- package/dist/prompt.js +0 -3
- package/dist/run-outcome.d.ts +3 -11
- package/dist/run-outcome.js +5 -0
- package/dist/run-session.d.ts +11 -7
- package/dist/run-session.js +184 -113
- package/dist/run-store.d.ts +32 -7
- package/dist/run-store.js +23 -1
- package/dist/scaffold.js +0 -5
- package/dist/schema.d.ts +117 -22
- package/dist/schema.js +22 -7
- package/dist/solo.js +1 -2
- package/dist/status.d.ts +16 -7
- package/dist/status.js +49 -1
- package/dist/validate.js +41 -38
- package/dist/worktree.d.ts +19 -12
- package/dist/worktree.js +35 -74
- package/package.json +1 -1
package/dist/policy.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { findOnPath } from "./fs-util.js";
|
|
4
|
-
import { globStaticPrefix } from "./paths.js";
|
|
5
4
|
export function commonFile(project) {
|
|
6
5
|
return path.join(project.paths.shared, "common", "COMMON.md");
|
|
7
6
|
}
|
|
@@ -123,8 +122,6 @@ export function writePolicy(project, agent) {
|
|
|
123
122
|
readDirs: shared && !mayWriteShared ? [shared] : [],
|
|
124
123
|
allowFiles,
|
|
125
124
|
deny: [...new Set(deny)],
|
|
126
|
-
owns: agent.owns,
|
|
127
|
-
restrictRepoToOwns: project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead,
|
|
128
125
|
};
|
|
129
126
|
}
|
|
130
127
|
let sandboxProbe;
|
|
@@ -139,16 +136,19 @@ function osSandboxAvailable() {
|
|
|
139
136
|
sandboxProbe = false;
|
|
140
137
|
return sandboxProbe;
|
|
141
138
|
}
|
|
139
|
+
/** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
|
|
140
|
+
export function setOsSandboxForTest(available) {
|
|
141
|
+
sandboxProbe = available;
|
|
142
|
+
}
|
|
142
143
|
export function enforcementFor(project, agent) {
|
|
143
144
|
const pol = writePolicy(project, agent);
|
|
144
145
|
const os = osSandboxAvailable();
|
|
145
146
|
const notes = [];
|
|
146
|
-
const parallel = project.dispatcher.max_parallel > 1;
|
|
147
147
|
const isLeadAgent = agent.name === project.lead;
|
|
148
148
|
let e;
|
|
149
149
|
if (agent.runtime === "claude-code") {
|
|
150
150
|
const lvl = os ? "os" : "tool-rules";
|
|
151
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl
|
|
151
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : lvl, repoInstructions: lvl };
|
|
152
152
|
if (!os)
|
|
153
153
|
notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
|
|
154
154
|
if (!isLeadAgent && !os)
|
|
@@ -156,7 +156,7 @@ export function enforcementFor(project, agent) {
|
|
|
156
156
|
}
|
|
157
157
|
else {
|
|
158
158
|
const lvl = os ? "os" : "prompt-only";
|
|
159
|
-
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc"
|
|
159
|
+
e = { memory: lvl, agentMd: lvl, otherContext: lvl, shared: isLeadAgent ? "n/a" : os ? "os" : "post-hoc", repoInstructions: "post-hoc" };
|
|
160
160
|
notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
|
|
161
161
|
if (!isLeadAgent && !os)
|
|
162
162
|
notes.push("Without the OS sandbox only the handoff index (_shared/MEMORY.md) is checked after each wake-up; other files in _shared are not protected.");
|
|
@@ -171,10 +171,6 @@ export function enforcementFor(project, agent) {
|
|
|
171
171
|
notes,
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
|
-
/** Static directory prefixes (absolute) of an agent's `owns` globs. */
|
|
175
|
-
export function ownsDirs(project, agent) {
|
|
176
|
-
return agent.owns.map((g) => path.join(project.dir, globStaticPrefix(g)));
|
|
177
|
-
}
|
|
178
174
|
/** Can `agent` legitimately change this protected file? */
|
|
179
175
|
export function mayEditProtected(project, agent, file) {
|
|
180
176
|
return writePolicy(project, agent).allowFiles.includes(file);
|
package/dist/process-runner.js
CHANGED
|
@@ -83,12 +83,14 @@ export function runInvocation(inv, opts) {
|
|
|
83
83
|
void Promise.all([endStream(outLog), endStream(errLog)]).then(() => {
|
|
84
84
|
if (spawnError) {
|
|
85
85
|
const hint = spawnError.code === "ENOENT" ? ` ("${inv.cmd}" not found on PATH)` : "";
|
|
86
|
-
|
|
86
|
+
// a missing binary is a crash the chain may route around (the next candidate can be another CLI); other spawn errors stay unclassified
|
|
87
|
+
const kind = hint ? "crash" : "unknown";
|
|
88
|
+
return resolve({ ok: false, text: "", exitCode: null, timedOut: false, error: `${spawnError.message}${hint}`, kind });
|
|
87
89
|
}
|
|
88
90
|
if (reason === "cancel")
|
|
89
91
|
return resolve({ ...cancelled, exitCode: exitCode ?? null });
|
|
90
92
|
if (reason === "timeout")
|
|
91
|
-
return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s
|
|
93
|
+
return resolve({ ok: false, text: "", exitCode: exitCode ?? null, timedOut: true, error: `timed out after ${opts.timeoutSec}s`, kind: "timeout" });
|
|
92
94
|
const parsed = parser
|
|
93
95
|
? parser.finish({ code: exitCode ?? null, stdoutTail: stdoutTail.text(), stderrTail: stderrTail.text() })
|
|
94
96
|
: inv.parse({ stdout: fallback.length ? fallback.toString("utf8") : stdoutTail.text(), stderr: stderrTail.text(), code: exitCode ?? null });
|
package/dist/project-lock.d.ts
CHANGED
|
@@ -4,10 +4,13 @@ export declare const LOCK_FILE = "lock.json";
|
|
|
4
4
|
export declare const LOCKS_DIR = "locks";
|
|
5
5
|
export interface LockOptions {
|
|
6
6
|
heartbeatMs?: number;
|
|
7
|
-
/**
|
|
8
|
-
|
|
7
|
+
/** The run works directly in the project checkout (not in its own worktree): only one such run may be active. It is written with `exclusive` too, so older binaries keep out. */
|
|
8
|
+
checkout?: boolean;
|
|
9
9
|
/** The holder needs the whole project (e.g. a memory tidy): refused while any other lock is alive, and it keeps every other lock out while held. */
|
|
10
10
|
exclusive?: boolean;
|
|
11
|
+
/** For a checkout run's refusal message: the checkout directory, and why this run has no worktree. */
|
|
12
|
+
dir?: string;
|
|
13
|
+
reason?: string;
|
|
11
14
|
}
|
|
12
15
|
export interface LockInfo {
|
|
13
16
|
token: string;
|
|
@@ -20,7 +23,12 @@ export interface LockInfo {
|
|
|
20
23
|
heartbeat_at: string;
|
|
21
24
|
/** Held by an operation that needs the project to itself (a memory tidy). */
|
|
22
25
|
exclusive?: boolean;
|
|
26
|
+
/** `true`: works in the project checkout; `false`: in its own worktree (or holds no files, like `clear`). Absent: an older version's run, treated as a checkout run. */
|
|
27
|
+
checkout?: boolean;
|
|
23
28
|
}
|
|
29
|
+
export type LockKind = "checkout" | "whole" | "worktree";
|
|
30
|
+
/** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
|
|
31
|
+
export declare function lockKind(l: Pick<LockInfo, "exclusive" | "checkout">): LockKind;
|
|
24
32
|
export interface ProjectLease {
|
|
25
33
|
readonly info: LockInfo;
|
|
26
34
|
/** True once another process replaced our lock file; the run should stop writing. */
|
package/dist/project-lock.js
CHANGED
|
@@ -10,6 +10,16 @@ export const LOCK_FILE = "lock.json";
|
|
|
10
10
|
export const LOCKS_DIR = "locks";
|
|
11
11
|
const ADMIT_FILE = ".admit";
|
|
12
12
|
const HEARTBEAT_MS = 5000;
|
|
13
|
+
/** What a lock protects: the checkout, the whole project (tidy/restore), or nothing shared (a worktree run). Locks of older versions count as checkout runs. */
|
|
14
|
+
export function lockKind(l) {
|
|
15
|
+
if (l.exclusive && l.checkout === true)
|
|
16
|
+
return "checkout";
|
|
17
|
+
if (l.exclusive)
|
|
18
|
+
return "whole";
|
|
19
|
+
if (l.checkout === false)
|
|
20
|
+
return "worktree";
|
|
21
|
+
return "checkout";
|
|
22
|
+
}
|
|
13
23
|
/** Field 22 (starttime, in clock ticks) of a /proc/<pid>/stat line. The command name (field 2) may contain spaces and ")". */
|
|
14
24
|
export function parseProcStat(text) {
|
|
15
25
|
const end = text.lastIndexOf(")");
|
|
@@ -137,10 +147,9 @@ function heldError(info) {
|
|
|
137
147
|
return new Error(`Run ${info.run_id} is still running (pid ${info.pid}, heartbeat ${Number.isFinite(late) ? late : "?"}s ago). ` +
|
|
138
148
|
`A run cannot be started or resumed twice; wait for it to end, or stop it first.`);
|
|
139
149
|
}
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
`wait for one to end, stop one, or raise max_concurrent_runs in project.yaml.`);
|
|
150
|
+
function checkoutBusyError(holder, dir, reason) {
|
|
151
|
+
return new Error(`Run ${holder.run_id} (pid ${holder.pid}) is working directly in ${dir}, and this run would too (${reason}), so only one can be active. ` +
|
|
152
|
+
`Wait for it to end or stop it; in a git repo with a commit, runs work in their own worktrees and can run side by side.`);
|
|
144
153
|
}
|
|
145
154
|
function exclusiveError(holder) {
|
|
146
155
|
return new Error(`${holder.run_id} (pid ${holder.pid}) needs the project to itself (a memory tidy or restore); wait for it to finish, then try again.`);
|
|
@@ -214,7 +223,6 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
214
223
|
assertRunId(runId);
|
|
215
224
|
fs.mkdirSync(path.join(root, LOCKS_DIR), { recursive: true });
|
|
216
225
|
const file = runLockFile(root, runId);
|
|
217
|
-
const max = opts.maxConcurrent ?? 1;
|
|
218
226
|
const now = new Date().toISOString();
|
|
219
227
|
const info = {
|
|
220
228
|
token: crypto.randomBytes(16).toString("hex"),
|
|
@@ -224,8 +232,10 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
224
232
|
run_id: runId,
|
|
225
233
|
acquired_at: now,
|
|
226
234
|
heartbeat_at: now,
|
|
227
|
-
...(opts.exclusive ? { exclusive: true } : {}),
|
|
235
|
+
...(opts.exclusive || opts.checkout ? { exclusive: true } : {}),
|
|
236
|
+
checkout: opts.checkout === true,
|
|
228
237
|
};
|
|
238
|
+
const kind = lockKind(info);
|
|
229
239
|
withAdmission(root, () => {
|
|
230
240
|
// 1. This run's own lock: a live holder means it is already running; a dead one is replaced.
|
|
231
241
|
const own = readLockFile(file);
|
|
@@ -246,13 +256,14 @@ export function acquireProjectLock(root, runId, opts = {}) {
|
|
|
246
256
|
fs.rmSync(legacyFile(root), { force: true });
|
|
247
257
|
// 3. The other runs that are active now.
|
|
248
258
|
const active = listRunLocks(root).filter((l) => l.run_id !== runId && lockHolderAlive(l));
|
|
249
|
-
const
|
|
250
|
-
if (
|
|
251
|
-
throw exclusiveError(
|
|
252
|
-
if (
|
|
259
|
+
const whole = active.find((l) => lockKind(l) === "whole");
|
|
260
|
+
if (whole)
|
|
261
|
+
throw exclusiveError(whole);
|
|
262
|
+
if (kind === "whole" && active.length)
|
|
253
263
|
throw needsIdleError(active);
|
|
254
|
-
|
|
255
|
-
|
|
264
|
+
const busy = kind === "checkout" ? active.find((l) => lockKind(l) === "checkout") : undefined;
|
|
265
|
+
if (busy)
|
|
266
|
+
throw checkoutBusyError(busy, opts.dir ?? root, opts.reason ?? "no worktree is available for it");
|
|
256
267
|
if (!tryCreate(file, info))
|
|
257
268
|
throw new Error(`Could not take the run lock ${file}; another process just took it. Try again.`);
|
|
258
269
|
});
|
package/dist/prompt.js
CHANGED
|
@@ -131,9 +131,6 @@ export function buildSystemPrompt(project, agent) {
|
|
|
131
131
|
lines.push("", "## Rules");
|
|
132
132
|
lines.push("- Never edit any `AGENT.md`, `COMMON.md`, or the repository's own `CLAUDE.md`/`AGENTS.md`" +
|
|
133
133
|
(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)"}.`, PR_ATTRIBUTION_RULE, NO_DAEMONS_RULE);
|
|
134
|
-
if (project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead) {
|
|
135
|
-
lines.push(`- You own these repo paths and must only change files there: ${agent.owns.join(", ")}.`);
|
|
136
|
-
}
|
|
137
134
|
return lines.join("\n");
|
|
138
135
|
}
|
|
139
136
|
/** 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. */
|
package/dist/run-outcome.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DoneContract } from "./format.js";
|
|
2
|
-
import type { EndReason } from "./run-store.js";
|
|
2
|
+
import type { EndReason, RunFailure } from "./run-store.js";
|
|
3
3
|
import type { RunOutcome } from "./schema.js";
|
|
4
4
|
export interface UnintegratedWork {
|
|
5
5
|
agent: string;
|
|
@@ -16,17 +16,9 @@ export declare function resolveDoneOutcome(contract: DoneContract, rejections: n
|
|
|
16
16
|
export declare function endOutcome(endReason: EndReason, done?: {
|
|
17
17
|
outcome: RunOutcome;
|
|
18
18
|
note?: string;
|
|
19
|
-
}, failure?: {
|
|
20
|
-
agent: string;
|
|
21
|
-
error?: string;
|
|
22
|
-
attempts: number;
|
|
23
|
-
}): {
|
|
19
|
+
}, failure?: RunFailure): {
|
|
24
20
|
outcome: RunOutcome;
|
|
25
21
|
note?: string;
|
|
26
22
|
};
|
|
27
23
|
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
28
|
-
export declare function failureGuide(project: string, run: string, f:
|
|
29
|
-
agent: string;
|
|
30
|
-
error?: string;
|
|
31
|
-
attempts: number;
|
|
32
|
-
}, runDir: string): string;
|
|
24
|
+
export declare function failureGuide(project: string, run: string, f: RunFailure, runDir: string): string;
|
package/dist/run-outcome.js
CHANGED
|
@@ -35,9 +35,14 @@ export function endOutcome(endReason, done, failure) {
|
|
|
35
35
|
}
|
|
36
36
|
/** What to tell the user when an agent kept failing and the run stopped: where to look, what to fix, how to go on. */
|
|
37
37
|
export function failureGuide(project, run, f, runDir) {
|
|
38
|
+
// With a fallback chain: every model given up on, in order.
|
|
39
|
+
const tried = f.tried?.length
|
|
40
|
+
? ["Models tried, in order:", ...f.tried.map((t, i) => ` ${i + 1}. ${t.model ? `${t.runtime}/${t.model}` : t.runtime}: ${t.kind ?? "unknown error"}${t.error ? ` (${t.error})` : ""}`)]
|
|
41
|
+
: [];
|
|
38
42
|
return [
|
|
39
43
|
"",
|
|
40
44
|
`${f.agent} was called ${f.attempts} time(s) and failed every time (${f.error ?? "unknown error"}). The run stopped; nothing was lost: the message ${f.agent} was handling is still unread and the work done so far is kept.`,
|
|
45
|
+
...tried,
|
|
41
46
|
"To fix it:",
|
|
42
47
|
` 1. Read why it failed: ${path.join(runDir, "log.jsonl")} (event "wake"), and the full output of each attempt in ${path.join(runDir, "mail", "attempts")}`,
|
|
43
48
|
` 2. Fix the cause: the ${f.agent} CLI (installed, logged in, model name), a timeout (dispatcher.wake_timeout_sec), or its AGENT.md / the message it keeps failing on. Check with: agent-lyceum doctor -p ${project}`,
|
package/dist/run-session.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type Invoker } from "./adapters/index.js";
|
|
2
2
|
import type { ResolvedProject } from "./config.js";
|
|
3
3
|
import type { RunOutcome } from "./schema.js";
|
|
4
|
-
import { type ResultBranch } from "./worktree.js";
|
|
5
|
-
import { newRunId, type EndReason, type RunState } from "./run-store.js";
|
|
4
|
+
import { type ResultBranch, type WorkspacePlan } from "./worktree.js";
|
|
5
|
+
import { newRunId, type EndReason, type RunFailure, type RunState } from "./run-store.js";
|
|
6
6
|
import { type PreparedTask } from "./task.js";
|
|
7
7
|
import { type CommandRunner, type PrOutcome } from "./open-pr.js";
|
|
8
8
|
export { newRunId };
|
|
@@ -36,11 +36,7 @@ export interface RunSummary {
|
|
|
36
36
|
/** The project's name, for the commands printed after the run. */
|
|
37
37
|
project: string;
|
|
38
38
|
/** The run stopped because this agent kept failing. */
|
|
39
|
-
failure?:
|
|
40
|
-
agent: string;
|
|
41
|
-
error?: string;
|
|
42
|
-
attempts: number;
|
|
43
|
-
};
|
|
39
|
+
failure?: RunFailure;
|
|
44
40
|
}
|
|
45
41
|
export interface RunOptions {
|
|
46
42
|
project: ResolvedProject;
|
|
@@ -52,6 +48,8 @@ export interface RunOptions {
|
|
|
52
48
|
task?: PreparedTask;
|
|
53
49
|
/** Continue this earlier run (same run dir, sessions and counters) instead of starting a new one. */
|
|
54
50
|
resume?: RunState;
|
|
51
|
+
/** Where the run's agents work, decided by `planWorkspace` before the run's lock was taken; the session never recomputes it. */
|
|
52
|
+
plan: WorkspacePlan;
|
|
55
53
|
/** Reopen a done run: this text becomes a new task for the lead. Only with `resume` of a run that ended `done`. */
|
|
56
54
|
followup?: {
|
|
57
55
|
text: string;
|
|
@@ -63,6 +61,8 @@ export interface RunOptions {
|
|
|
63
61
|
/** Stop the run when this fires: running agents are killed, unread mail is kept, the run ends `cancelled` and can be resumed. */
|
|
64
62
|
signal?: AbortSignal;
|
|
65
63
|
log?: (line: string) => void;
|
|
64
|
+
/** The wait between retries of a model with a fallback chain; tests pass one that does not wait. Default: a timer cut short by `signal`. */
|
|
65
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
66
66
|
}
|
|
67
67
|
export declare class RunSession {
|
|
68
68
|
private readonly opts;
|
|
@@ -87,6 +87,8 @@ export declare class RunSession {
|
|
|
87
87
|
private doneOutcome;
|
|
88
88
|
private doneNote;
|
|
89
89
|
private failure;
|
|
90
|
+
/** Models cooling down after a rate limit or quota error; one table for the run, shared by parallel wake-ups. */
|
|
91
|
+
private readonly cooldowns;
|
|
90
92
|
private doneVerification;
|
|
91
93
|
/** The accepted `done` carried `open_pr: true` (only the lead can send `done`). */
|
|
92
94
|
private openPrRequested;
|
|
@@ -111,6 +113,8 @@ export declare class RunSession {
|
|
|
111
113
|
private pendingAgents;
|
|
112
114
|
private pickBatch;
|
|
113
115
|
private wake;
|
|
116
|
+
/** Copy the shared cooldown table into the state (only once something has cooled down); true when it was. */
|
|
117
|
+
private syncCooldowns;
|
|
114
118
|
/**
|
|
115
119
|
* A finished member's files go from its worktree into the repo before anyone reads its mail, so the lead
|
|
116
120
|
* sees them. When that cannot be done, nothing is applied, the member's work stays on its branch, and the lead is told.
|