agent-lyceum 0.25.1 → 0.27.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.
Files changed (45) hide show
  1. package/README.md +6 -2
  2. package/README.zh-TW.md +6 -2
  3. package/dist/call.js +2 -0
  4. package/dist/config.d.ts +29 -1
  5. package/dist/config.js +34 -3
  6. package/dist/doctor-instances.d.ts +4 -0
  7. package/dist/doctor-instances.js +63 -0
  8. package/dist/doctor.d.ts +2 -0
  9. package/dist/doctor.js +17 -1
  10. package/dist/duplicate-notices.d.ts +9 -0
  11. package/dist/duplicate-notices.js +34 -0
  12. package/dist/fallback.d.ts +4 -2
  13. package/dist/fallback.js +11 -4
  14. package/dist/guard.d.ts +17 -1
  15. package/dist/guard.js +37 -6
  16. package/dist/instance-containment.d.ts +15 -0
  17. package/dist/instance-containment.js +36 -0
  18. package/dist/instances.d.ts +183 -0
  19. package/dist/instances.js +268 -0
  20. package/dist/mailbox.d.ts +46 -2
  21. package/dist/mailbox.js +121 -36
  22. package/dist/message-store.d.ts +47 -1
  23. package/dist/message-store.js +75 -7
  24. package/dist/paths.d.ts +7 -0
  25. package/dist/paths.js +27 -2
  26. package/dist/policy.d.ts +1 -0
  27. package/dist/policy.js +2 -2
  28. package/dist/prompt.d.ts +11 -0
  29. package/dist/prompt.js +66 -10
  30. package/dist/run-session.d.ts +35 -2
  31. package/dist/run-session.js +337 -20
  32. package/dist/run-store.d.ts +17 -3
  33. package/dist/run-store.js +17 -7
  34. package/dist/scaffold.d.ts +3 -0
  35. package/dist/scaffold.js +10 -1
  36. package/dist/schema.d.ts +19 -0
  37. package/dist/schema.js +12 -0
  38. package/dist/status.d.ts +7 -0
  39. package/dist/status.js +74 -15
  40. package/dist/tiers.d.ts +27 -0
  41. package/dist/tiers.js +49 -0
  42. package/dist/validate.js +28 -1
  43. package/dist/worktree.d.ts +6 -0
  44. package/dist/worktree.js +16 -2
  45. package/package.json +1 -1
package/dist/mailbox.js CHANGED
@@ -2,9 +2,12 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import YAML from "yaml";
5
+ import { duplicateNoticeKey, shouldNotifyDuplicate } from "./duplicate-notices.js";
5
6
  import { formatWarning, missingSections, parseSteps } from "./format.js";
6
- import { atomicWrite } from "./fs-util.js";
7
+ import { atomicWrite, readRegularFileNoFollow } from "./fs-util.js";
7
8
  import { countFormatRejection, formatRejectionKey, formatRejections, MAX_FORMAT_REJECTIONS } from "./format-rejections.js";
9
+ import { parallelFlag, roleOfName } from "./instances.js";
10
+ import { effectiveTier, tierFlag } from "./tiers.js";
8
11
  import { deterministicId, fault, RouteJournal, sourceIdOf } from "./message-store.js";
9
12
  import { inboxDir, outboxDir, sharedDir } from "./policy.js";
10
13
  import { activeRecordsBytes, RECORD_KINDS, RECORDS_BUDGET } from "./records.js";
@@ -38,14 +41,17 @@ export function ensureProjectDirs(project) {
38
41
  const handoff = sharedDir(project);
39
42
  if (handoff)
40
43
  fs.mkdirSync(handoff, { recursive: true });
41
- for (const name of Object.keys(project.agents)) {
42
- fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
43
- fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
44
- const a = must(project.agents[name], `agent ${name}`);
45
- for (const m of [a.memory.global, a.memory.project, a.memory.task])
46
- if (m)
47
- fs.mkdirSync(m, { recursive: true });
48
- }
44
+ for (const name of Object.keys(project.agents))
45
+ ensureAgentDirs(project, name);
46
+ }
47
+ /** The mailbox and memory directories of one agent (a newly opened instance gets just its own). */
48
+ export function ensureAgentDirs(project, name) {
49
+ fs.mkdirSync(path.join(inboxDir(project, name), "read"), { recursive: true });
50
+ fs.mkdirSync(path.join(outboxDir(project, name), "rejected"), { recursive: true });
51
+ const a = must(project.agents[name], `agent ${name}`);
52
+ for (const m of [a.memory.global, a.memory.project, a.memory.task])
53
+ if (m)
54
+ fs.mkdirSync(m, { recursive: true });
49
55
  }
50
56
  /** Deliver a message into `to`'s inbox (dispatcher-side; senders other than agents allowed). */
51
57
  export function deliver(project, input) {
@@ -60,6 +66,7 @@ export function deliver(project, input) {
60
66
  subject: input.subject,
61
67
  created: input.created ?? new Date().toISOString(),
62
68
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
69
+ ...(input.tier ? { tier: input.tier } : {}),
63
70
  };
64
71
  const file = path.join(inboxDir(project, input.to), input.fileName ?? `${stamp()}-${input.from}-${id}.md`);
65
72
  atomicWrite(file, serialize(meta, input.body));
@@ -70,7 +77,7 @@ export function deliver(project, input) {
70
77
  * the plan (id, file name, timestamp) is journaled before anything is written, a retry writes the identical file,
71
78
  * and mail the recipient already read is never written again. Without a run (no journal) it just delivers.
72
79
  */
73
- export function deliverOnce(project, journal, sourceKey, input) {
80
+ export function deliverOnce(project, journal, sourceKey, input, extra = {}) {
74
81
  if (!journal)
75
82
  return { msg: deliver(project, input), fresh: true };
76
83
  const prior = journal.get(sourceKey, input.to);
@@ -81,14 +88,23 @@ export function deliverOnce(project, journal, sourceKey, input) {
81
88
  msg_id: deterministicId(sourceKey, input.to),
82
89
  file: `${stamp()}-${input.from}-${deterministicId(sourceKey, input.to)}.md`,
83
90
  created: new Date().toISOString(),
91
+ ...(extra.held ? { held: true } : {}),
92
+ ...(extra.parallel ? { parallel: true } : {}),
93
+ ...(extra.from ? { from: extra.from } : {}),
94
+ ...(extra.about ? { about: extra.about } : {}),
84
95
  });
85
96
  fault("route:intent");
86
- const dest = path.join(inboxDir(project, input.to), planned.file);
97
+ // A replay goes where the first attempt was planned to go: the journal's `held`, not the current state of the run.
98
+ const held = planned.held === true;
99
+ const inbox = inboxDir(project, held ? roleOfName(project.agents, input.to) : input.to);
100
+ const dest = path.join(held ? path.join(inbox, PENDING_DIR) : inbox, planned.file);
87
101
  const meta = deliveredMeta(project, input, planned);
88
102
  const want = serialize(meta, input.body);
89
103
  const alreadyRead = fs.existsSync(path.join(path.dirname(dest), "read", planned.file));
104
+ // A held mail that was delivered (or assigned) may have been moved out of `pending/` already: it is never written again.
105
+ const settled = held && (planned.delivered || journal.assigned(planned.msg_id) !== undefined);
90
106
  // Write the planned file; if one is already there, verify it instead of trusting it. Mail already read is never written again.
91
- if (!alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
107
+ if (!settled && !alreadyRead && (!fs.existsSync(dest) || fs.readFileSync(dest, "utf8") !== want))
92
108
  atomicWrite(dest, want);
93
109
  fault("route:written");
94
110
  const fresh = !planned.delivered;
@@ -97,6 +113,8 @@ export function deliverOnce(project, journal, sourceKey, input) {
97
113
  fault("route:delivered");
98
114
  return { msg: { meta, body: input.body, file: dest }, fresh };
99
115
  }
116
+ /** Mail for a role that has instances waits here, in the role's inbox, until the assigner places it. */
117
+ export const PENDING_DIR = "pending";
100
118
  function deliveredMeta(project, input, planned) {
101
119
  return {
102
120
  id: planned.msg_id,
@@ -108,6 +126,7 @@ function deliveredMeta(project, input, planned) {
108
126
  subject: input.subject,
109
127
  created: planned.created,
110
128
  ...(project.run?.layout === "run" ? { run_id: project.run.id } : {}),
129
+ ...(input.tier ? { tier: input.tier } : {}),
111
130
  };
112
131
  }
113
132
  export function readMessage(file) {
@@ -115,6 +134,14 @@ export function readMessage(file) {
115
134
  const meta = data;
116
135
  return { meta, body, file };
117
136
  }
137
+ /** `readMessage` for files that may be anything (held mail): a symlink, FIFO or other non-regular entry throws instead of being followed or blocking. */
138
+ export function readMessageNoFollow(file) {
139
+ const buf = readRegularFileNoFollow(file);
140
+ if (!buf)
141
+ throw new Error(`${file} is not a readable regular file`);
142
+ const { data, body } = parseRaw(buf.toString("utf8"));
143
+ return { meta: data, body, file };
144
+ }
118
145
  /** Paths of the unread messages in an agent's inbox, oldest name first. Does not open them. */
119
146
  export function unreadFiles(project, agent) {
120
147
  const dir = inboxDir(project, agent);
@@ -176,12 +203,15 @@ function sameText(body) {
176
203
  * An earlier mail from `from` to `to` with the same type, the same reply target and the same text (unread or already read):
177
204
  * sending it again changes nothing and is how two agents end up in a loop. Texts too short to mean anything are never duplicates.
178
205
  */
179
- function earlierDuplicate(project, from, to, type, replyTo, body) {
206
+ function earlierDuplicate(project, from, to, type, replyTo, body, held = false, tier = "high") {
180
207
  const text = sameText(body);
181
208
  if (text.length < 20)
182
209
  return undefined;
183
210
  const dir = inboxDir(project, to);
184
- for (const d of [dir, path.join(dir, "read")]) {
211
+ const role = roleOfName(project.agents, to);
212
+ const slots = held ? Object.values(project.agents).filter((a) => roleOfName(project.agents, a.name) === role).map((a) => inboxDir(project, a.name)) : [dir];
213
+ const dirs = held ? [...slots.flatMap((d) => [d, path.join(d, "read")]), path.join(inboxDir(project, role), PENDING_DIR)] : [dir, path.join(dir, "read")];
214
+ for (const d of dirs) {
185
215
  if (!fs.existsSync(d))
186
216
  continue;
187
217
  for (const e of fs.readdirSync(d, { withFileTypes: true })) {
@@ -189,7 +219,7 @@ function earlierDuplicate(project, from, to, type, replyTo, body) {
189
219
  continue;
190
220
  try {
191
221
  const m = readMessage(path.join(d, e.name));
192
- if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text)
222
+ if (m.meta.from === from && m.meta.type === type && (m.meta.reply_to ?? "") === (replyTo ?? "") && sameText(m.body) === text && effectiveTier(m.meta) === tier)
193
223
  return m;
194
224
  }
195
225
  catch {
@@ -200,8 +230,8 @@ function earlierDuplicate(project, from, to, type, replyTo, body) {
200
230
  return undefined;
201
231
  }
202
232
  /** Validate each agent's outbox and move accepted mail into recipients' inboxes. Safe to run again after a crash. */
203
- export function routeOutboxes(project) {
204
- const res = { delivered: [], rejected: [], warnings: [], asks: [] };
233
+ export function routeOutboxes(project, routing) {
234
+ const res = { delivered: [], rejected: [], warnings: [], asks: [], tierIgnored: [] };
205
235
  const journal = project.run ? new RouteJournal(project.run.dir) : undefined;
206
236
  for (const sender of Object.values(project.agents)) {
207
237
  const dir = outboxDir(project, sender.name);
@@ -214,14 +244,15 @@ export function routeOutboxes(project) {
214
244
  .sort((a, b) => fs.statSync(a).mtimeMs - fs.statSync(b).mtimeMs || a.localeCompare(b));
215
245
  for (const file of files) {
216
246
  const source = sourceIdOf(sender.name, file);
217
- const reject = (reason) => {
218
- deliverOnce(project, journal, `reject:${source}`, {
219
- from: "dispatcher",
220
- to: sender.name,
221
- type: "failure",
222
- subject: `Message ${path.basename(file)} was rejected`,
223
- body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
224
- });
247
+ const reject = (reason, notify = true) => {
248
+ if (notify)
249
+ deliverOnce(project, journal, `reject:${source}`, {
250
+ from: "dispatcher",
251
+ to: sender.name,
252
+ type: "failure",
253
+ subject: `Message ${path.basename(file)} was rejected`,
254
+ body: `Your outbox message \`${path.basename(file)}\` was not delivered: ${reason}\n\nIt was moved to \`rejected/\`.`,
255
+ });
225
256
  const dest = path.join(dir, "rejected", path.basename(file));
226
257
  fs.mkdirSync(path.dirname(dest), { recursive: true });
227
258
  fs.renameSync(file, dest);
@@ -276,7 +307,15 @@ export function routeOutboxes(project) {
276
307
  reject(`missing "to".`);
277
308
  continue;
278
309
  }
279
- const bad = targets.find((t) => !project.agents[t] || t === sender.name || (sender.canMessage !== "all" && !sender.canMessage.includes(t)));
310
+ const roleOf = (n) => roleOfName(project.agents, n);
311
+ if (sender.instance) {
312
+ const same = targets.find((t) => t !== sender.name && roleOf(t) === roleOf(sender.name));
313
+ if (same !== undefined) {
314
+ reject(`cannot message another instance of your own role ("${same}" is an instance of "${roleOf(sender.name)}"). Put what it needs in your reply to the lead.`);
315
+ continue;
316
+ }
317
+ }
318
+ const bad = targets.find((t) => !project.agents[t] || t === sender.name || (sender.canMessage !== "all" && !sender.canMessage.includes(roleOf(t))));
280
319
  if (bad !== undefined) {
281
320
  const why = !project.agents[bad]
282
321
  ? `no such agent "${bad}"`
@@ -286,6 +325,30 @@ export function routeOutboxes(project) {
286
325
  reject(why + ".");
287
326
  continue;
288
327
  }
328
+ // `parallel` is read here from the outbox file, but only a verdict on it is kept: it goes to the journal (never back into the mail).
329
+ const flags = new Map();
330
+ if (routing && d.parallel !== undefined) {
331
+ for (const t of targets) {
332
+ const role = roleOf(t);
333
+ flags.set(t, parallelFlag({ value: d.parallel, senderIsLead: sender.name === project.lead, type, replyTo: d.reply_to, thread: d.thread, cap: routing.cap(role) }));
334
+ }
335
+ if ([...flags.values()].includes("invalid")) {
336
+ reject(`parallel must be true or false (YAML boolean), not ${JSON.stringify(d.parallel)}. Write \`parallel: true\` on a new task to run it in a parallel instance, or leave the line out.`);
337
+ continue;
338
+ }
339
+ }
340
+ // `tier` is judged per recipient like `parallel`, before the duplicate check, which needs the effective tier (spec 24.5.2, 24.5.4).
341
+ const tiers = new Map();
342
+ for (const t of targets)
343
+ tiers.set(t, tierFlag({ value: d.tier, senderIsLead: sender.name === project.lead, type, recipientTiers: project.agents[t]?.tiers }));
344
+ if ([...tiers.values()].includes("invalid")) {
345
+ reject(`tier must be low, mid or high, not ${JSON.stringify(d.tier)}. Write \`tier: low\` (or mid/high) on a task to run it on a cheaper model, or leave the line out to use the member's own model.`);
346
+ continue;
347
+ }
348
+ const tierOf = (to) => {
349
+ const f = tiers.get(to);
350
+ return f === "low" || f === "mid" || f === "high" ? f : undefined;
351
+ };
289
352
  const missing = missingSections(type, parsed.body);
290
353
  if (missing.length && project.run?.layout === "run") {
291
354
  const key = formatRejectionKey(sender.name, type, subject);
@@ -302,7 +365,7 @@ export function routeOutboxes(project) {
302
365
  for (const to of targets) {
303
366
  if (journal?.get(source, to))
304
367
  continue;
305
- const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body);
368
+ const earlier = earlierDuplicate(project, sender.name, to, type, replyTo, parsed.body, !!routing?.held(roleOf(to)), tierOf(to) ?? "high");
306
369
  if (earlier)
307
370
  repeats.set(to, earlier);
308
371
  }
@@ -310,18 +373,25 @@ export function routeOutboxes(project) {
310
373
  .map(([to, m]) => `to "${to}" it repeats mail ${m.meta.id} ("${m.meta.subject}", ${m.meta.created}) with the same content`)
311
374
  .join("; ");
312
375
  const REPEAT_HELP = "Do not send it again: if that mail has not been answered yet, wait for the reply; if you need something different, change what you ask for; if the work is finished, the lead sends `done`.";
376
+ // A failure mail wakes the sender; an agent that answers with the same text again would be woken forever, so after
377
+ // MAX_DUPLICATE_NOTICES notices for the same repeated mail the repeat is still set aside and logged, but not announced.
378
+ // Bound to a run directory (either layout); a project-only mailbox has no place to count and stays unbounded.
379
+ const notifyRepeat = !repeats.size ||
380
+ !project.run ||
381
+ shouldNotifyDuplicate(project.run.dir, duplicateNoticeKey(sender.name, [...repeats].map(([to, m]) => [to, m.meta.id])), source);
313
382
  if (repeats.size === targets.length) {
314
- reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`);
383
+ reject(`it is a duplicate: ${repeatNote()}, so it was not sent again. ${REPEAT_HELP}`, notifyRepeat);
315
384
  continue;
316
385
  }
317
386
  if (repeats.size) {
318
- deliverOnce(project, journal, `dup:${source}`, {
319
- from: "dispatcher",
320
- to: sender.name,
321
- type: "failure",
322
- subject: `Part of ${path.basename(file)} was not sent`,
323
- body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
324
- });
387
+ if (notifyRepeat)
388
+ deliverOnce(project, journal, `dup:${source}`, {
389
+ from: "dispatcher",
390
+ to: sender.name,
391
+ type: "failure",
392
+ subject: `Part of ${path.basename(file)} was not sent`,
393
+ body: `Your outbox message \`${path.basename(file)}\` was delivered, except that ${repeatNote()}. ${REPEAT_HELP}`,
394
+ });
325
395
  res.rejected.push({ from: sender.name, file, reason: `duplicate: ${repeatNote()}` });
326
396
  }
327
397
  const body = missing.length ? `${formatWarning(type, missing)}\n\n${parsed.body}` : parsed.body;
@@ -333,7 +403,22 @@ export function routeOutboxes(project) {
333
403
  for (const to of targets) {
334
404
  if (repeats.has(to))
335
405
  continue;
336
- const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo });
406
+ const flag = flags.get(to);
407
+ const role = roleOf(to);
408
+ const tf = tiers.get(to);
409
+ if (typeof tf === "object" && !journal?.get(source, to))
410
+ res.tierIgnored.push({ from: sender.name, id: deterministicId(source, to), to, subject, type, reason: tf.ignored });
411
+ if (typeof flag === "object" && !journal?.get(source, to))
412
+ routing?.ignored(flag.ignored, { from: sender.name, id: deterministicId(source, to), to, subject });
413
+ // The first valid parallel task makes the role's instances durable before a single file of this mail exists.
414
+ if (routing && flag === "on" && !routing.held(role))
415
+ routing.enable(role);
416
+ const held = !!routing?.held(role);
417
+ const { msg, fresh } = deliverOnce(project, journal, source, { from: sender.name, to, type, subject, body, thread, reply_to: replyTo, ...(tierOf(to) ? { tier: tierOf(to) } : {}) }, {
418
+ ...(held ? { held: true } : {}),
419
+ ...(held && flag === "on" ? { parallel: true } : {}),
420
+ ...(routing?.held(roleOf(sender.name)) ? { from: sender.name } : {}),
421
+ });
337
422
  if (!fresh)
338
423
  continue;
339
424
  res.delivered.push({ from: sender.name, to, id: msg.meta.id, file: msg.file, type, subject });
@@ -9,15 +9,61 @@ export interface JournalEntry {
9
9
  file: string;
10
10
  created: string;
11
11
  delivered: boolean;
12
+ /** Same-role instances (spec 23): the mail waits in `inbox/<role>/pending/` for the assigner. Only written for an activated role. */
13
+ held?: true;
14
+ /** The lead marked it `parallel: true` (read only from here, never from the mail file). */
15
+ parallel?: true;
16
+ /** The instance of an activated role that sent it. */
17
+ from?: string;
18
+ /** The instance a dispatcher notice is about. */
19
+ about?: string;
20
+ /** Position of the intent among the journal's records (a line cut short by a crash does not count). */
21
+ seq?: number;
22
+ }
23
+ /** What the journal knows about one message id: where the assigner put it, who sent it or what it is about, and its delivery line. */
24
+ export interface Ownership {
25
+ assign?: string;
26
+ from?: string;
27
+ about?: string;
28
+ intent?: {
29
+ seq: number;
30
+ to: string;
31
+ file: string;
32
+ source: string;
33
+ msg_id: string;
34
+ };
12
35
  }
13
36
  /** Append-only log of deliveries. A delivery is identified by `(source, recipient)`, never by a random id. */
14
37
  export declare class RouteJournal {
15
38
  private readonly file;
16
39
  private readonly entries;
40
+ private readonly byMsg;
41
+ private readonly byFileName;
42
+ private readonly assigns;
43
+ private readonly firstHeldSeq;
44
+ private seqNext;
17
45
  constructor(runDir: string);
46
+ /** Enter an intent in every index; `seq` is its position among the records read or appended. */
47
+ private index;
18
48
  get(source: string, to: string): JournalEntry | undefined;
19
49
  private append;
20
- intent(e: Omit<JournalEntry, "delivered">): JournalEntry;
50
+ intent(e: Omit<JournalEntry, "delivered" | "seq">): JournalEntry;
51
+ /** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
52
+ assign(a: {
53
+ msg_id: string;
54
+ file: string;
55
+ role: string;
56
+ slot: string;
57
+ }): void;
58
+ assigned(msgId: string): {
59
+ slot: string;
60
+ file: string;
61
+ role: string;
62
+ } | undefined;
63
+ byFile(file: string): JournalEntry | undefined;
64
+ /** Position of the first held intent addressed to `name`; undefined when there is none. */
65
+ firstHeld(name: string): number | undefined;
66
+ ownership(msgId: string): Ownership | undefined;
21
67
  delivered(source: string, to: string): void;
22
68
  }
23
69
  /** Stable id of one outbox file instance: who wrote it, its name, when, and what it said. */
@@ -15,6 +15,11 @@ export const mailDir = (runDir) => path.join(runDir, "mail");
15
15
  export class RouteJournal {
16
16
  file;
17
17
  entries = new Map();
18
+ byMsg = new Map();
19
+ byFileName = new Map();
20
+ assigns = new Map();
21
+ firstHeldSeq = new Map();
22
+ seqNext = 0;
18
23
  constructor(runDir) {
19
24
  this.file = path.join(mailDir(runDir), "journal.jsonl");
20
25
  let text = "";
@@ -30,27 +35,65 @@ export class RouteJournal {
30
35
  try {
31
36
  const j = JSON.parse(line);
32
37
  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 });
38
+ if (j.t === "intent") {
39
+ this.index({
40
+ source: j.source,
41
+ to: j.to,
42
+ msg_id: j.msg_id,
43
+ file: j.file,
44
+ created: j.created,
45
+ delivered: false,
46
+ ...(j.held === true ? { held: true } : {}),
47
+ ...(j.parallel === true ? { parallel: true } : {}),
48
+ ...(typeof j.from === "string" ? { from: j.from } : {}),
49
+ ...(typeof j.about === "string" ? { about: j.about } : {}),
50
+ });
51
+ }
35
52
  else if (j.t === "delivered") {
36
53
  const e = this.entries.get(key);
37
54
  if (e)
38
55
  e.delivered = true;
39
56
  }
57
+ else if (j.t === "assign" && typeof j.msg_id === "string" && typeof j.slot === "string") {
58
+ this.assigns.set(j.msg_id, { slot: j.slot, file: String(j.file), role: String(j.role) });
59
+ }
40
60
  }
41
61
  catch {
42
62
  /* a line cut short by a crash: ignore it */
43
63
  }
44
64
  }
45
65
  }
66
+ /** Enter an intent in every index; `seq` is its position among the records read or appended. */
67
+ index(e) {
68
+ e.seq = this.seqNext++;
69
+ this.entries.set(`${e.source}\u0000${e.to}`, e);
70
+ this.byMsg.set(e.msg_id, e);
71
+ this.byFileName.set(e.file, e);
72
+ if (e.held && !this.firstHeldSeq.has(e.to))
73
+ this.firstHeldSeq.set(e.to, e.seq);
74
+ return e;
75
+ }
46
76
  get(source, to) {
47
77
  return this.entries.get(`${source}\u0000${to}`);
48
78
  }
49
79
  append(obj) {
50
80
  fs.mkdirSync(path.dirname(this.file), { recursive: true });
51
- const fd = fs.openSync(this.file, "a");
81
+ const fd = fs.openSync(this.file, "a+");
52
82
  try {
53
- fs.writeSync(fd, JSON.stringify(obj) + "\n");
83
+ let text = `${JSON.stringify(obj)}\n`;
84
+ // A crash or a short write can leave the last line without its newline; appending straight after it would glue the two into one
85
+ // unreadable line. The real tail is read on every append (one byte), so no cached check can go stale, even with another writer.
86
+ const size = fs.fstatSync(fd).size;
87
+ if (size > 0) {
88
+ const last = Buffer.alloc(1);
89
+ fs.readSync(fd, last, 0, 1, size - 1);
90
+ if (last[0] !== 0x0a)
91
+ text = `\n${text}`;
92
+ }
93
+ const bytes = Buffer.from(text);
94
+ const written = fs.writeSync(fd, bytes);
95
+ if (written < bytes.length)
96
+ throw new Error(`Short write to ${this.file}: ${written} of ${bytes.length} bytes (disk full?). Resume the run after freeing space.`);
54
97
  fs.fsyncSync(fd);
55
98
  }
56
99
  finally {
@@ -59,9 +102,34 @@ export class RouteJournal {
59
102
  }
60
103
  intent(e) {
61
104
  this.append({ t: "intent", ...e });
62
- const entry = { ...e, delivered: false };
63
- this.entries.set(`${e.source}\u0000${e.to}`, entry);
64
- return entry;
105
+ return this.index({ ...e, delivered: false });
106
+ }
107
+ /** The assigner's decision for a held mail, made durable before the file moves (spec 23.7.2). */
108
+ assign(a) {
109
+ this.append({ t: "assign", ...a });
110
+ this.assigns.set(a.msg_id, { slot: a.slot, file: a.file, role: a.role });
111
+ }
112
+ assigned(msgId) {
113
+ return this.assigns.get(msgId);
114
+ }
115
+ byFile(file) {
116
+ return this.byFileName.get(file);
117
+ }
118
+ /** Position of the first held intent addressed to `name`; undefined when there is none. */
119
+ firstHeld(name) {
120
+ return this.firstHeldSeq.get(name);
121
+ }
122
+ ownership(msgId) {
123
+ const intent = this.byMsg.get(msgId);
124
+ const assign = this.assigns.get(msgId)?.slot;
125
+ if (!intent && assign === undefined)
126
+ return undefined;
127
+ return {
128
+ ...(assign !== undefined ? { assign } : {}),
129
+ ...(intent?.from ? { from: intent.from } : {}),
130
+ ...(intent?.about ? { about: intent.about } : {}),
131
+ ...(intent ? { intent: { seq: intent.seq, to: intent.to, file: intent.file, source: intent.source, msg_id: intent.msg_id } } : {}),
132
+ };
65
133
  }
66
134
  delivered(source, to) {
67
135
  this.append({ t: "delivered", source, to });
package/dist/paths.d.ts CHANGED
@@ -23,5 +23,12 @@ export interface ProjectPaths {
23
23
  taskMemory: string;
24
24
  }
25
25
  export declare function projectPaths(home: string, project: string): ProjectPaths;
26
+ /** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
27
+ export declare function escapesParent(rel: string, sep?: string): boolean;
26
28
  /** True when `child` is `parent` or inside it. */
27
29
  export declare function isInside(parent: string, child: string): boolean;
30
+ /**
31
+ * `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
32
+ * yet (a directory about to be made) still compares equal to the same place reached through a symlink.
33
+ */
34
+ export declare function canonicalPath(p: string): string;
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
  export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
@@ -43,8 +44,32 @@ export function projectPaths(home, project) {
43
44
  taskMemory: path.join(root, "task-memory"),
44
45
  };
45
46
  }
47
+ /** True when a relative path leaves its base: it is `..`, starts with a `..` segment, or is absolute. A name like `..x` is not an escape. */
48
+ export function escapesParent(rel, sep = path.sep) {
49
+ return rel === ".." || rel.startsWith(`..${sep}`) || path.isAbsolute(rel);
50
+ }
46
51
  /** True when `child` is `parent` or inside it. */
47
52
  export function isInside(parent, child) {
48
- const rel = path.relative(parent, child);
49
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
53
+ return !escapesParent(path.relative(parent, child));
54
+ }
55
+ /**
56
+ * `p` with symlinks resolved as far as it exists: the real path of its deepest existing ancestor plus the rest. A path that does not exist
57
+ * yet (a directory about to be made) still compares equal to the same place reached through a symlink.
58
+ */
59
+ export function canonicalPath(p) {
60
+ const abs = path.resolve(p);
61
+ const rest = [];
62
+ let head = abs;
63
+ for (;;) {
64
+ try {
65
+ return path.join(fs.realpathSync.native(head), ...rest);
66
+ }
67
+ catch {
68
+ const up = path.dirname(head);
69
+ if (up === head)
70
+ return abs;
71
+ rest.unshift(path.basename(head));
72
+ head = up;
73
+ }
74
+ }
50
75
  }
package/dist/policy.d.ts CHANGED
@@ -44,6 +44,7 @@ export declare const scratchCwd: (workDir: string) => string;
44
44
  /** The repo directory this agent works in: its own worktree, or the main repo. */
45
45
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
46
46
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
47
+ export declare function osSandboxAvailable(): boolean;
47
48
  /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
48
49
  export declare function setOsSandboxForTest(available: boolean | undefined): void;
49
50
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
package/dist/policy.js CHANGED
@@ -136,13 +136,13 @@ export function writePolicy(project, agent) {
136
136
  deny.push(path.join(ws.root, ".git"), ...["commondir", "config.worktree", "gitdir"].map((f) => path.join(ws.gitDir, f)));
137
137
  return {
138
138
  allowDirs,
139
- readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)])],
139
+ readDirs: [...(shared && !mayWriteShared ? [shared] : []), ...(isLead ? [] : [recordsDir(project)]), ...Object.values(agent.sharedMemory ?? {})],
140
140
  allowFiles,
141
141
  deny: [...new Set(deny)],
142
142
  };
143
143
  }
144
144
  let sandboxProbe;
145
- function osSandboxAvailable() {
145
+ export function osSandboxAvailable() {
146
146
  if (sandboxProbe !== undefined)
147
147
  return sandboxProbe;
148
148
  if (process.platform === "darwin")
package/dist/prompt.d.ts CHANGED
@@ -1,6 +1,17 @@
1
1
  import type { ResolvedAgent, ResolvedProject } from "./config.js";
2
2
  import type { Message } from "./mailbox.js";
3
3
  import type { Layer, MemoryFile, MissingPath, ProjectChanges } from "./memory-tidy.js";
4
+ /** Lead-only rules that keep the number of wake-ups down; placed after the progress-tracking block. */
5
+ export declare const WAKE_ECONOMY_SECTION: string[];
6
+ /** The lead's system prompt gets this at its very end when same-role instances are possible in the run (spec 23.12). No trailing newline. */
7
+ export declare const PARALLEL_SECTION: (n: number) => string;
8
+ /** The lead's system prompt ends with this when a member can run a task on a cheaper model (spec 24.10); `members` = [name, model of low, mid, high]. No trailing newline. */
9
+ export declare const TIER_SECTION: (members: {
10
+ name: string;
11
+ low: string;
12
+ mid: string;
13
+ high: string;
14
+ }[]) => string;
4
15
  export declare const COMMON_LIMIT: number;
5
16
  export declare const MEMORY_INDEX_LIMIT: number;
6
17
  export declare const HANDOFF_LIMIT: number;