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,91 @@
1
+ export declare function setFaultHook(fn: ((step: string) => void) | undefined): void;
2
+ export declare function fault(step: string): void;
3
+ export declare const mailDir: (runDir: string) => string;
4
+ export interface JournalEntry {
5
+ source: string;
6
+ to: string;
7
+ msg_id: string;
8
+ /** Inbox file name chosen when the delivery was planned, so a retry writes the same file. */
9
+ file: string;
10
+ created: string;
11
+ delivered: boolean;
12
+ }
13
+ /** Append-only log of deliveries. A delivery is identified by `(source, recipient)`, never by a random id. */
14
+ export declare class RouteJournal {
15
+ private readonly file;
16
+ private readonly entries;
17
+ constructor(runDir: string);
18
+ get(source: string, to: string): JournalEntry | undefined;
19
+ private append;
20
+ intent(e: Omit<JournalEntry, "delivered">): JournalEntry;
21
+ delivered(source: string, to: string): void;
22
+ }
23
+ /** Stable id of one outbox file instance: who wrote it, its name, when, and what it said. */
24
+ export declare function sourceIdOf(sender: string, file: string): string;
25
+ export declare function deterministicId(sourceKey: string, to: string): string;
26
+ export type ClaimStatus = "claimed" | "output_ready" | "committed" | "abandoned";
27
+ export interface ClaimRecord {
28
+ id: string;
29
+ agent: string;
30
+ message_ids: string[];
31
+ files: string[];
32
+ status: ClaimStatus;
33
+ created: string;
34
+ }
35
+ export declare function loadClaim(runDir: string, id: string): ClaimRecord;
36
+ /** Record that `agent` is about to handle these messages. They stay unread until `commitClaim`. */
37
+ export declare function claimMessages(runDir: string, agent: string, messages: {
38
+ file: string;
39
+ meta: {
40
+ id: string;
41
+ };
42
+ }[]): ClaimRecord;
43
+ /** The wake-up was stopped before it finished: its input stays unread and will be handled again. */
44
+ export declare function abandonClaim(runDir: string, claimId: string): void;
45
+ /** The wake-up finished and left its output in the outbox; from here on the output is routed, not redone. */
46
+ export declare function markOutputReady(runDir: string, claimId: string): void;
47
+ /** Mark the claim committed, then move its input to `read/`. Safe to repeat after a crash. */
48
+ export declare function commitClaim(runDir: string, claimId: string): void;
49
+ export type AttemptStatus = "started" | "output_ready" | "committed" | "failed";
50
+ export interface AttemptRecord {
51
+ /** `<claim id>-a<n>` */
52
+ id: string;
53
+ claim_id: string;
54
+ agent: string;
55
+ n: number;
56
+ status: AttemptStatus;
57
+ started_at: string;
58
+ finished_at?: string;
59
+ error?: string;
60
+ /** Outbox files set aside because this attempt did not succeed. */
61
+ parked?: string[];
62
+ }
63
+ export declare const attemptLogDir: (runDir: string, id: string) => string;
64
+ export declare const attemptOutboxDir: (runDir: string, id: string) => string;
65
+ export declare function loadAttempt(runDir: string, id: string): AttemptRecord;
66
+ export declare function listAttempts(runDir: string): AttemptRecord[];
67
+ /** Start the next attempt for a claim (a1, a2, ...). */
68
+ export declare function beginAttempt(runDir: string, claimId: string): AttemptRecord;
69
+ export declare function finishAttempt(runDir: string, attemptId: string, status: Exclude<AttemptStatus, "started">, extra?: {
70
+ error?: string;
71
+ parked?: string[];
72
+ }): AttemptRecord;
73
+ /** Move whatever is in an agent's outbox into the attempt's own folder, so it can never be mistaken for a later attempt's result. */
74
+ export declare function parkOutbox(runDir: string, outbox: string, attemptId: string): string[];
75
+ export interface RecoveryReport {
76
+ /** Committed claims whose input had not been moved to `read/` yet; now it has. */
77
+ finalized: string[];
78
+ /** Claims that never produced output; their input stays unread and will be handled again. */
79
+ abandoned: ClaimRecord[];
80
+ /** Claims whose wake-up finished but whose output was not fully routed yet; the caller routes, then commits them. */
81
+ ready: ClaimRecord[];
82
+ /** Attempts that were running when the process died: failed now, their outbox set aside. External side effects may have happened. */
83
+ interrupted: {
84
+ attempt: AttemptRecord;
85
+ parked: string[];
86
+ }[];
87
+ }
88
+ /** Bring a run's claims back to a consistent state. Call before waking anyone on resume. */
89
+ export declare function recoverRunMail(runDir: string, opts?: {
90
+ outboxOf?: (agent: string) => string;
91
+ }): RecoveryReport;
@@ -0,0 +1,225 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { atomicWrite } from "./fs-util.js";
5
+ /** Test seam: called after every durable step of routing and claim commits, so a test can "kill the process" there. */
6
+ let faultHook;
7
+ export function setFaultHook(fn) {
8
+ faultHook = fn;
9
+ }
10
+ export function fault(step) {
11
+ faultHook?.(step);
12
+ }
13
+ export const mailDir = (runDir) => path.join(runDir, "mail");
14
+ /** Append-only log of deliveries. A delivery is identified by `(source, recipient)`, never by a random id. */
15
+ export class RouteJournal {
16
+ file;
17
+ entries = new Map();
18
+ constructor(runDir) {
19
+ this.file = path.join(mailDir(runDir), "journal.jsonl");
20
+ let text = "";
21
+ try {
22
+ text = fs.readFileSync(this.file, "utf8");
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ for (const line of text.split("\n")) {
28
+ if (!line.trim())
29
+ continue;
30
+ try {
31
+ const j = JSON.parse(line);
32
+ const key = `${j.source}\u0000${j.to}`;
33
+ if (j.t === "intent")
34
+ this.entries.set(key, { source: j.source, to: j.to, msg_id: j.msg_id, file: j.file, created: j.created, delivered: false });
35
+ else if (j.t === "delivered") {
36
+ const e = this.entries.get(key);
37
+ if (e)
38
+ e.delivered = true;
39
+ }
40
+ }
41
+ catch {
42
+ /* a line cut short by a crash: ignore it */
43
+ }
44
+ }
45
+ }
46
+ get(source, to) {
47
+ return this.entries.get(`${source}\u0000${to}`);
48
+ }
49
+ append(obj) {
50
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
51
+ const fd = fs.openSync(this.file, "a");
52
+ try {
53
+ fs.writeSync(fd, JSON.stringify(obj) + "\n");
54
+ fs.fsyncSync(fd);
55
+ }
56
+ finally {
57
+ fs.closeSync(fd);
58
+ }
59
+ }
60
+ intent(e) {
61
+ this.append({ t: "intent", ...e });
62
+ const entry = { ...e, delivered: false };
63
+ this.entries.set(`${e.source}\u0000${e.to}`, entry);
64
+ return entry;
65
+ }
66
+ delivered(source, to) {
67
+ this.append({ t: "delivered", source, to });
68
+ const e = this.entries.get(`${source}\u0000${to}`);
69
+ if (e)
70
+ e.delivered = true;
71
+ }
72
+ }
73
+ /** Stable id of one outbox file instance: who wrote it, its name, when, and what it said. */
74
+ export function sourceIdOf(sender, file) {
75
+ const st = fs.statSync(file);
76
+ return crypto
77
+ .createHash("sha256")
78
+ .update(`${sender}\0${path.basename(file)}\0${Math.round(st.mtimeMs)}\0`)
79
+ .update(fs.readFileSync(file))
80
+ .digest("hex")
81
+ .slice(0, 16);
82
+ }
83
+ export function deterministicId(sourceKey, to) {
84
+ return crypto.createHash("sha256").update(`${sourceKey}\0${to}`).digest("hex").slice(0, 8);
85
+ }
86
+ const claimFile = (runDir, id) => path.join(mailDir(runDir), "claims", `${id}.json`);
87
+ function saveClaim(runDir, c) {
88
+ atomicWrite(claimFile(runDir, c.id), JSON.stringify(c, null, 2));
89
+ }
90
+ export function loadClaim(runDir, id) {
91
+ return JSON.parse(fs.readFileSync(claimFile(runDir, id), "utf8"));
92
+ }
93
+ /** Record that `agent` is about to handle these messages. They stay unread until `commitClaim`. */
94
+ export function claimMessages(runDir, agent, messages) {
95
+ const rec = {
96
+ id: `${Date.now().toString(36)}-${agent}-${crypto.randomBytes(3).toString("hex")}`,
97
+ agent,
98
+ message_ids: messages.map((m) => m.meta.id),
99
+ files: messages.map((m) => m.file),
100
+ status: "claimed",
101
+ created: new Date().toISOString(),
102
+ };
103
+ saveClaim(runDir, rec);
104
+ return rec;
105
+ }
106
+ /** The wake-up was stopped before it finished: its input stays unread and will be handled again. */
107
+ export function abandonClaim(runDir, claimId) {
108
+ const c = loadClaim(runDir, claimId);
109
+ if (c.status === "claimed" || c.status === "output_ready")
110
+ saveClaim(runDir, { ...c, status: "abandoned" });
111
+ }
112
+ /** The wake-up finished and left its output in the outbox; from here on the output is routed, not redone. */
113
+ export function markOutputReady(runDir, claimId) {
114
+ const c = loadClaim(runDir, claimId);
115
+ if (c.status === "claimed")
116
+ saveClaim(runDir, { ...c, status: "output_ready" });
117
+ }
118
+ function moveToRead(file) {
119
+ if (!fs.existsSync(file))
120
+ return false;
121
+ const dest = path.join(path.dirname(file), "read", path.basename(file));
122
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
123
+ fs.renameSync(file, dest);
124
+ return true;
125
+ }
126
+ /** Mark the claim committed, then move its input to `read/`. Safe to repeat after a crash. */
127
+ export function commitClaim(runDir, claimId) {
128
+ const c = loadClaim(runDir, claimId);
129
+ if (c.status !== "committed")
130
+ saveClaim(runDir, { ...c, status: "committed" });
131
+ fault("claim:marked");
132
+ for (const f of c.files) {
133
+ moveToRead(f);
134
+ fault("claim:moved");
135
+ }
136
+ }
137
+ const attemptFile = (runDir, id) => path.join(mailDir(runDir), "attempts", `${id}.json`);
138
+ export const attemptLogDir = (runDir, id) => path.join(mailDir(runDir), "attempts", id, "log");
139
+ export const attemptOutboxDir = (runDir, id) => path.join(mailDir(runDir), "attempts", id, "outbox");
140
+ function saveAttempt(runDir, a) {
141
+ atomicWrite(attemptFile(runDir, a.id), JSON.stringify(a, null, 2));
142
+ }
143
+ export function loadAttempt(runDir, id) {
144
+ return JSON.parse(fs.readFileSync(attemptFile(runDir, id), "utf8"));
145
+ }
146
+ export function listAttempts(runDir) {
147
+ const dir = path.join(mailDir(runDir), "attempts");
148
+ if (!fs.existsSync(dir))
149
+ return [];
150
+ const out = [];
151
+ for (const f of fs.readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
152
+ try {
153
+ out.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")));
154
+ }
155
+ catch {
156
+ /* skip */
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+ /** Start the next attempt for a claim (a1, a2, ...). */
162
+ export function beginAttempt(runDir, claimId) {
163
+ const claim = loadClaim(runDir, claimId);
164
+ const n = listAttempts(runDir).filter((a) => a.claim_id === claimId).length + 1;
165
+ const rec = { id: `${claimId}-a${n}`, claim_id: claimId, agent: claim.agent, n, status: "started", started_at: new Date().toISOString() };
166
+ saveAttempt(runDir, rec);
167
+ return rec;
168
+ }
169
+ export function finishAttempt(runDir, attemptId, status, extra = {}) {
170
+ const rec = { ...loadAttempt(runDir, attemptId), status, finished_at: new Date().toISOString(), ...extra };
171
+ saveAttempt(runDir, rec);
172
+ return rec;
173
+ }
174
+ /** Move whatever is in an agent's outbox into the attempt's own folder, so it can never be mistaken for a later attempt's result. */
175
+ export function parkOutbox(runDir, outbox, attemptId) {
176
+ if (!fs.existsSync(outbox))
177
+ return [];
178
+ const moved = [];
179
+ const dest = attemptOutboxDir(runDir, attemptId);
180
+ for (const e of fs.readdirSync(outbox, { withFileTypes: true })) {
181
+ if (!e.isFile() || e.name.startsWith("."))
182
+ continue;
183
+ fs.mkdirSync(dest, { recursive: true });
184
+ fs.renameSync(path.join(outbox, e.name), path.join(dest, e.name));
185
+ moved.push(e.name);
186
+ }
187
+ return moved;
188
+ }
189
+ /** Bring a run's claims back to a consistent state. Call before waking anyone on resume. */
190
+ export function recoverRunMail(runDir, opts = {}) {
191
+ const report = { finalized: [], abandoned: [], ready: [], interrupted: [] };
192
+ for (const a of listAttempts(runDir)) {
193
+ if (a.status !== "started")
194
+ continue;
195
+ const parked = opts.outboxOf ? parkOutbox(runDir, opts.outboxOf(a.agent), a.id) : [];
196
+ const done = finishAttempt(runDir, a.id, "failed", { error: "interrupted", parked });
197
+ report.interrupted.push({ attempt: done, parked });
198
+ }
199
+ const dir = path.join(mailDir(runDir), "claims");
200
+ if (!fs.existsSync(dir))
201
+ return report;
202
+ for (const name of fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
203
+ let c;
204
+ try {
205
+ c = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
206
+ }
207
+ catch {
208
+ continue;
209
+ }
210
+ if (c.status === "committed") {
211
+ let moved = false;
212
+ for (const f of c.files)
213
+ moved = moveToRead(f) || moved;
214
+ if (moved)
215
+ report.finalized.push(c.id);
216
+ }
217
+ else if (c.status === "claimed") {
218
+ saveClaim(runDir, { ...c, status: "abandoned" });
219
+ report.abandoned.push({ ...c, status: "abandoned" });
220
+ }
221
+ else if (c.status === "output_ready")
222
+ report.ready.push(c);
223
+ }
224
+ return report;
225
+ }
@@ -0,0 +1,30 @@
1
+ export declare const NAME_RE: RegExp;
2
+ export declare function expandHome(p: string): string;
3
+ /** Resolve a path from a config file: expand `~`, then resolve relative to `base`. */
4
+ export declare function absPath(p: string, base: string): string;
5
+ export declare function resolveHome(opt?: string): string;
6
+ export declare function assertName(kind: string, name: string): void;
7
+ export interface HomePaths {
8
+ home: string;
9
+ globalConfig: string;
10
+ globalAgentsDir: string;
11
+ projectsDir: string;
12
+ }
13
+ export declare function homePaths(home: string): HomePaths;
14
+ export interface ProjectPaths {
15
+ root: string;
16
+ config: string;
17
+ agentsDir: string;
18
+ shared: string;
19
+ common: string;
20
+ inboxRoot: string;
21
+ outboxRoot: string;
22
+ runs: string;
23
+ /** Per-task memory: <taskMemory>/<run-id>/<agent>. Never shared between runs. */
24
+ taskMemory: string;
25
+ }
26
+ export declare function projectPaths(home: string, project: string): ProjectPaths;
27
+ /** True when `child` is `parent` or inside it. */
28
+ export declare function isInside(parent: string, child: string): boolean;
29
+ /** Directory part of a glob that precedes the first wildcard segment. */
30
+ export declare function globStaticPrefix(glob: string): string;
package/dist/paths.js ADDED
@@ -0,0 +1,62 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
4
+ export function expandHome(p) {
5
+ if (p === "~")
6
+ return os.homedir();
7
+ if (p.startsWith("~/"))
8
+ return path.join(os.homedir(), p.slice(2));
9
+ return p;
10
+ }
11
+ /** Resolve a path from a config file: expand `~`, then resolve relative to `base`. */
12
+ export function absPath(p, base) {
13
+ return path.resolve(base, expandHome(p));
14
+ }
15
+ export function resolveHome(opt) {
16
+ const raw = opt ?? process.env.AGENT_LYCEUM_HOME ?? path.join(os.homedir(), "agent-lyceum-config");
17
+ return path.resolve(expandHome(raw));
18
+ }
19
+ export function assertName(kind, name) {
20
+ if (!NAME_RE.test(name)) {
21
+ throw new Error(`Invalid ${kind} name "${name}": use letters, digits, "-" or "_" (must not start with "-" or "_").`);
22
+ }
23
+ }
24
+ export function homePaths(home) {
25
+ return {
26
+ home,
27
+ globalConfig: path.join(home, "team.yaml"),
28
+ globalAgentsDir: path.join(home, "agents"),
29
+ projectsDir: path.join(home, "projects"),
30
+ };
31
+ }
32
+ export function projectPaths(home, project) {
33
+ const root = path.join(home, "projects", project);
34
+ const shared = path.join(root, "shared");
35
+ return {
36
+ root,
37
+ config: path.join(root, "project.yaml"),
38
+ agentsDir: path.join(root, "agents"),
39
+ shared,
40
+ common: path.join(shared, "COMMON.md"),
41
+ inboxRoot: path.join(shared, "inbox"),
42
+ outboxRoot: path.join(shared, "outbox"),
43
+ runs: path.join(root, "runs"),
44
+ taskMemory: path.join(root, "task-memory"),
45
+ };
46
+ }
47
+ /** True when `child` is `parent` or inside it. */
48
+ export function isInside(parent, child) {
49
+ const rel = path.relative(parent, child);
50
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
51
+ }
52
+ /** Directory part of a glob that precedes the first wildcard segment. */
53
+ export function globStaticPrefix(glob) {
54
+ const segs = glob.split("/");
55
+ const out = [];
56
+ for (const s of segs) {
57
+ if (/[*?[\]{}]/.test(s))
58
+ break;
59
+ out.push(s);
60
+ }
61
+ return out.join("/");
62
+ }
@@ -0,0 +1,38 @@
1
+ import type { ResolvedAgent, ResolvedProject } from "./config.js";
2
+ export type Level = "os" | "tool-rules" | "post-hoc" | "prompt-only" | "n/a";
3
+ export interface AgentEnforcement {
4
+ agent: string;
5
+ runtime: string;
6
+ memory: Level;
7
+ agentMd: Level;
8
+ otherContext: Level;
9
+ repoInstructions: Level;
10
+ owns: Level;
11
+ /** Absolute paths outside the repo this agent may write. */
12
+ writablePaths: string[];
13
+ notes: string[];
14
+ }
15
+ export interface WritePolicy {
16
+ /** Directories the agent may write to (besides the repo). */
17
+ allowDirs: string[];
18
+ /** Individual files the agent may write (outside allowDirs). */
19
+ allowFiles: string[];
20
+ /** Files/dirs that must stay unwritable (checked even inside the repo). */
21
+ deny: string[];
22
+ /** Repo-relative globs the agent owns (only enforced when max_parallel > 1). */
23
+ owns: string[];
24
+ restrictRepoToOwns: boolean;
25
+ }
26
+ export declare function commonFile(project: ResolvedProject): string;
27
+ export declare function outboxDir(project: ResolvedProject, agent: string): string;
28
+ export declare function inboxDir(project: ResolvedProject, agent: string): string;
29
+ export declare function repoInstructionFiles(project: ResolvedProject): string[];
30
+ export declare function memoryDirs(a: ResolvedAgent): string[];
31
+ /** The repo directory this agent works in: its own worktree, or the main repo. */
32
+ export declare function repoDirFor(project: ResolvedProject, agent: string): string;
33
+ export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
34
+ export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
35
+ /** Static directory prefixes (absolute) of an agent's `owns` globs. */
36
+ export declare function ownsDirs(project: ResolvedProject, agent: ResolvedAgent): string[];
37
+ /** Can `agent` legitimately change this protected file? */
38
+ export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
package/dist/policy.js ADDED
@@ -0,0 +1,143 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { globStaticPrefix } from "./paths.js";
5
+ export function commonFile(project) {
6
+ return path.join(project.paths.shared, "common", "COMMON.md");
7
+ }
8
+ export function outboxDir(project, agent) {
9
+ return path.join(project.paths.outboxRoot, agent);
10
+ }
11
+ export function inboxDir(project, agent) {
12
+ return path.join(project.paths.inboxRoot, agent);
13
+ }
14
+ export function repoInstructionFiles(project) {
15
+ return [path.join(project.dir, "CLAUDE.md"), path.join(project.dir, "AGENTS.md")];
16
+ }
17
+ export function memoryDirs(a) {
18
+ return [a.memory.global, a.memory.project, a.memory.task].filter((x) => !!x);
19
+ }
20
+ const RUN_ENTRIES = ["state.json", "log.jsonl", "result.md", "task.md", "snapshots", "agents", "mail"];
21
+ const MAIL_ENTRIES = ["inbox", "claims", "attempts", "journal.jsonl"];
22
+ function entries(dir) {
23
+ try {
24
+ return fs.readdirSync(dir);
25
+ }
26
+ catch {
27
+ return [];
28
+ }
29
+ }
30
+ /**
31
+ * The run records agents must not write. A run's mailboxes sit inside its run dir, and a deny on a parent would
32
+ * beat the allow on an agent's own outbox, so the run dir is denied entry by entry instead of as a whole.
33
+ */
34
+ function runDenyPaths(project) {
35
+ const runs = project.paths.runs;
36
+ if (project.run?.layout !== "run")
37
+ return [runs];
38
+ const runDir = project.run.dir;
39
+ const mail = path.join(runDir, "mail");
40
+ const unique = (xs) => [...new Set(xs)];
41
+ return unique([
42
+ ...entries(runs).filter((e) => e !== project.run.id).map((e) => path.join(runs, e)),
43
+ ...[...RUN_ENTRIES, ...entries(runDir)].filter((e) => e !== "mail").map((e) => path.join(runDir, e)),
44
+ ...[...MAIL_ENTRIES, ...entries(mail)].filter((e) => e !== "outbox").map((e) => path.join(mail, e)),
45
+ ]);
46
+ }
47
+ /** The repo directory this agent works in: its own worktree, or the main repo. */
48
+ export function repoDirFor(project, agent) {
49
+ return project.workspaces?.[agent]?.dir ?? project.dir;
50
+ }
51
+ export function writePolicy(project, agent) {
52
+ const isLead = agent.name === project.lead;
53
+ const ws = project.workspaces?.[agent.name];
54
+ const allowDirs = [...memoryDirs(agent), outboxDir(project, agent.name), ...(ws ? [ws.gitDir] : [])];
55
+ const allowFiles = [];
56
+ if (isLead)
57
+ allowFiles.push(commonFile(project));
58
+ if (agent.canEditAgentMd) {
59
+ for (const a of Object.values(project.agents))
60
+ allowFiles.push(a.agentMd);
61
+ allowFiles.push(...repoInstructionFiles(project));
62
+ }
63
+ const deny = [];
64
+ const allowedFileSet = new Set(allowFiles);
65
+ const addDeny = (p) => {
66
+ if (!allowedFileSet.has(p))
67
+ deny.push(p);
68
+ };
69
+ for (const a of Object.values(project.agents)) {
70
+ addDeny(a.agentMd);
71
+ if (a.name !== agent.name)
72
+ for (const m of memoryDirs(a))
73
+ deny.push(m);
74
+ if (a.name !== agent.name)
75
+ deny.push(outboxDir(project, a.name));
76
+ deny.push(inboxDir(project, a.name));
77
+ }
78
+ addDeny(commonFile(project));
79
+ for (const f of repoInstructionFiles(project))
80
+ addDeny(f);
81
+ deny.push(...runDenyPaths(project), project.paths.config, path.join(project.home, "team.yaml"));
82
+ // In a worktree: other agents' checkouts are off limits, and so are this checkout's own copies of the protected files.
83
+ for (const [name, other] of Object.entries(project.workspaces ?? {}))
84
+ if (name !== agent.name)
85
+ deny.push(other.root);
86
+ if (ws)
87
+ for (const f of ["CLAUDE.md", "AGENTS.md"])
88
+ addDeny(path.join(ws.dir, f));
89
+ return {
90
+ allowDirs,
91
+ allowFiles,
92
+ deny: [...new Set(deny)],
93
+ owns: agent.owns,
94
+ restrictRepoToOwns: project.dispatcher.max_parallel > 1 && agent.owns.length > 0 && !isLead,
95
+ };
96
+ }
97
+ let sandboxProbe;
98
+ function osSandboxAvailable() {
99
+ if (sandboxProbe !== undefined)
100
+ return sandboxProbe;
101
+ if (process.platform === "darwin")
102
+ sandboxProbe = true;
103
+ else if (process.platform === "linux")
104
+ sandboxProbe = spawnSync("which", ["bwrap"]).status === 0;
105
+ else
106
+ sandboxProbe = false;
107
+ return sandboxProbe;
108
+ }
109
+ export function enforcementFor(project, agent) {
110
+ const pol = writePolicy(project, agent);
111
+ const os = osSandboxAvailable();
112
+ const notes = [];
113
+ const parallel = project.dispatcher.max_parallel > 1;
114
+ let e;
115
+ if (agent.runtime === "claude-code") {
116
+ const lvl = os ? "os" : "tool-rules";
117
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: lvl, owns: parallel ? "tool-rules" : "n/a" };
118
+ if (!os)
119
+ notes.push("OS sandbox unavailable (Windows, or Linux without bubblewrap): Bash can bypass Edit rules.");
120
+ }
121
+ else {
122
+ const lvl = os ? "os" : "prompt-only";
123
+ e = { memory: lvl, agentMd: lvl, otherContext: lvl, repoInstructions: "post-hoc", owns: parallel ? "prompt-only" : "n/a" };
124
+ notes.push("Codex cannot forbid single files inside the repo; CLAUDE.md/AGENTS.md changes are detected and reverted after each wake-up.");
125
+ if (agent.canEditAgentMd)
126
+ notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
127
+ }
128
+ return {
129
+ agent: agent.name,
130
+ runtime: agent.runtime ?? "?",
131
+ ...e,
132
+ writablePaths: [...pol.allowDirs, ...pol.allowFiles.filter((f) => !f.startsWith(project.dir + path.sep))],
133
+ notes,
134
+ };
135
+ }
136
+ /** Static directory prefixes (absolute) of an agent's `owns` globs. */
137
+ export function ownsDirs(project, agent) {
138
+ return agent.owns.map((g) => path.join(project.dir, globStaticPrefix(g)));
139
+ }
140
+ /** Can `agent` legitimately change this protected file? */
141
+ export function mayEditProtected(project, agent, file) {
142
+ return writePolicy(project, agent).allowFiles.includes(file);
143
+ }
@@ -0,0 +1,21 @@
1
+ import type { Invocation, WakeResult } from "./adapters/types.js";
2
+ /** Most output kept in memory per stream; the rest only goes to the log files. */
3
+ export declare const TAIL_BYTES: number;
4
+ /** A stdout line longer than this is not offered to the adapter's parser (it is still logged). */
5
+ export declare const MAX_LINE_BYTES: number;
6
+ export interface RunnerOptions {
7
+ timeoutSec: number;
8
+ signal?: AbortSignal;
9
+ /** `stdout.log` and `stderr.log` of this invocation are written here, in full. */
10
+ logDir: string;
11
+ /** How long a stopping process gets between SIGTERM and SIGKILL. Default 5000. */
12
+ killGraceMs?: number;
13
+ /** How long to wait for output pipes to close after the process is gone or killed, before giving up on them. Default 1000. */
14
+ settleWaitMs?: number;
15
+ }
16
+ /**
17
+ * Run an invocation as a child process in its own process group, with a timeout, cancellation, bounded memory
18
+ * and a bounded finish: whatever the runtime and its descendants do with the output pipes, this settles once,
19
+ * and the whole process tree is gone by then (SIGTERM, then SIGKILL after `killGraceMs`).
20
+ */
21
+ export declare function runInvocation(inv: Invocation, opts: RunnerOptions): Promise<WakeResult>;