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/README.md CHANGED
@@ -40,7 +40,7 @@ Latest unreleased code: `npx github:gogogohuang/agent-lyceum <command>`.
40
40
  3. Agents reply by writing mail to their own `outbox/`; the dispatcher checks `can_message` and delivers it.
41
41
  4. The run ends when the lead sends `done`.
42
42
 
43
- Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Concurrency needs no configuration. In a git repo with a commit, every run works in its own git worktree and ends on its own branch (`agent-lyceum/<run>/…`), members with pending mail wake in parallel, and any number of runs can be active at once; the result is not written into your working tree, so use `git merge <branch>` or `open_pr`, and note that a run starts from `HEAD` and does not see your uncommitted edits. In a non-git folder, a repo with no commit, or a repo using reftable, the run works directly in the folder, one agent at a time (an info line says why), and only one such run can be active. agent-lyceum never merges. Upgrading from a version with `owns`, `max_parallel`, `max_concurrent_runs` or `workspace_mode`: see [Upgrading](docs/upgrading-run-v2.md). More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
43
+ Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyceum-config/`, not in your repo. Concurrency needs no configuration. In a git repo with a commit, every run works in its own git worktree and ends on its own branch (`agent-lyceum/<run>/…`), members with pending mail wake in parallel, and any number of runs can be active at once; the result is not written into your working tree, so use `git merge <branch>` or `open_pr`, and note that a run starts from `HEAD` and does not see your uncommitted edits. In a non-git folder, a repo with no commit, or a repo using reftable, the run works directly in the folder, one agent at a time (an info line says why), and only one such run can be active. A lead can mark independent tasks `parallel: true` so one member works on several at once, each in its own worktree (worktree mode only, the lead decides, at most `dispatcher.max_instances` per member, default 4); see [How a run works](docs/how-it-works.md#parallel-instances-of-one-member). agent-lyceum never merges. Upgrading from a version with `owns`, `max_parallel`, `max_concurrent_runs` or `workspace_mode`: see [Upgrading](docs/upgrading-run-v2.md). More: [How a run works](docs/how-it-works.md), [Configuration](docs/configuration.md).
44
44
 
45
45
  ## Commands
46
46
 
@@ -75,6 +75,10 @@ agents:
75
75
 
76
76
  Folder layout, every agent field and the rules `doctor` checks: [Configuration](docs/configuration.md).
77
77
 
78
+ ## Downgrading
79
+
80
+ Older versions refuse a `team.yaml` or `project.yaml` that contains `model_tiers` (a load error, "Unrecognized key"). Before using an older binary, remove `model_tiers` from every `project.yaml` and from `team.yaml`. Other mail and run state are unaffected: older versions ignore a `tier` line in mail. See [Model tiers](docs/configuration.md#model-tiers). (Spec 24.3.2, 24.13)
81
+
78
82
  ## Topics
79
83
 
80
84
  | Topic | Read |
@@ -82,6 +86,6 @@ Folder layout, every agent field and the rules `doctor` checks: [Configuration](
82
86
  | Run lifecycle, parallel agents, several runs at once, follow-ups, opening a PR | [How a run works](docs/how-it-works.md) |
83
87
  | What agents may write, sandbox levels, known gaps | [Write scope](docs/write-scope.md) |
84
88
  | Letting an agent search or fetch the web | [Web access](docs/web-access.md) |
85
- | Live run status inside Claude Code | [Monitor mod](docs/monitor.md) |
89
+ | Live run status inside Claude Code (a framed, state-coloured tree of agents; pane or full-width band) | [Monitor mod](docs/monitor.md) |
86
90
  | All commands in full | [Commands](docs/commands.md) |
87
91
  | Coming from an older version | [Upgrading to run v2](docs/upgrading-run-v2.md) |
package/README.zh-TW.md CHANGED
@@ -40,7 +40,7 @@ npx agent-lyceum status
40
40
  3. agent 把回信寫進自己的 `outbox/`,dispatcher 檢查 `can_message` 後投遞。
41
41
  4. lead 寄出 `done` 時,run 結束。
42
42
 
43
- 工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。並行不需要任何設定。在有 commit 的 git repo 裡,每個 run 都在自己的 git worktree 工作,並結束在自己的 branch(`agent-lyceum/<run>/…`);有待處理信件的成員會平行喚醒,同時也可有任意多個 run;結果不會寫進你的工作目錄,請用 `git merge <branch>` 或 `open_pr`,且 run 從 `HEAD` 起跑,看不到你未提交的修改。在非 git 資料夾、沒有 commit 的 repo 或使用 reftable 的 repo,run 直接在該資料夾工作、一次只跑一個 agent(會有一行說明原因),且同時只能有一個這樣的 run。agent-lyceum 不會幫你合併。從有 `owns`、`max_parallel`、`max_concurrent_runs` 或 `workspace_mode` 的版本升級:見[升級](docs/upgrading-run-v2.md)。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
43
+ 工具自己的資料(設定、信箱、log、記憶、worktree)放在 `~/agent-lyceum-config/`,不放進你的 repo。並行不需要任何設定。在有 commit 的 git repo 裡,每個 run 都在自己的 git worktree 工作,並結束在自己的 branch(`agent-lyceum/<run>/…`);有待處理信件的成員會平行喚醒,同時也可有任意多個 run;結果不會寫進你的工作目錄,請用 `git merge <branch>` 或 `open_pr`,且 run 從 `HEAD` 起跑,看不到你未提交的修改。在非 git 資料夾、沒有 commit 的 repo 或使用 reftable 的 repo,run 直接在該資料夾工作、一次只跑一個 agent(會有一行說明原因),且同時只能有一個這樣的 run。lead 可以把互不相依的 task 標成 `parallel: true`,讓一位成員同時處理多件,各在自己的 worktree(僅 worktree 模式、由 lead 決定、每位成員最多 `dispatcher.max_instances` 個,預設 4);見[一次執行如何運作](docs/how-it-works.zh-TW.md#同一成員的平行實例)。agent-lyceum 不會幫你合併。從有 `owns`、`max_parallel`、`max_concurrent_runs` 或 `workspace_mode` 的版本升級:見[升級](docs/upgrading-run-v2.md)。詳見[一次執行如何運作](docs/how-it-works.zh-TW.md)、[設定](docs/configuration.zh-TW.md)。
44
44
 
45
45
  ## 指令
46
46
 
@@ -75,6 +75,10 @@ agents:
75
75
 
76
76
  資料夾結構、所有 agent 欄位與 `doctor` 檢查的規則:[設定](docs/configuration.zh-TW.md)。
77
77
 
78
+ ## 降級(改用舊版)
79
+
80
+ 舊版程式會拒絕載入含 `model_tiers` 的 `team.yaml` 或 `project.yaml`(載入錯誤,「Unrecognized key」)。改用舊版前,請先從每個 `project.yaml` 與 `team.yaml` 拿掉 `model_tiers`。其他信件與 run 狀態不受影響:舊版會忽略信件中的 `tier` 行。見[模型分級](docs/configuration.zh-TW.md#模型分級)。(spec 24.3.2、24.13)
81
+
78
82
  ## 主題
79
83
 
80
84
  | 主題 | 閱讀 |
@@ -82,6 +86,6 @@ agents:
82
86
  | run 的生命週期、平行 agent、同時多個 run、追加需求、開 PR | [一次執行如何運作](docs/how-it-works.zh-TW.md) |
83
87
  | agent 能寫什麼、沙箱等級、已知缺口 | [寫入範圍](docs/write-scope.zh-TW.md) |
84
88
  | 讓 agent 搜尋或讀取網頁 | [網路存取](docs/web-access.zh-TW.md) |
85
- | 在 Claude Code 內看即時 run 狀態 | [監控 mod](docs/monitor.zh-TW.md) |
89
+ | 在 Claude Code 內看即時 run 狀態(每個 agent 一個依狀態上色的外框,樹狀排列;面板或整寬橫幅) | [監控 mod](docs/monitor.zh-TW.md) |
86
90
  | 所有指令的完整說明 | [指令](docs/commands.zh-TW.md) |
87
91
  | 從舊版升級 | [升級到 run v2](docs/upgrading-run-v2.md) |
package/dist/call.js CHANGED
@@ -55,6 +55,8 @@ export function callProject(home, agent, dir, callDir) {
55
55
  lead: agent.name,
56
56
  dispatcher: DISPATCHER_DEFAULTS,
57
57
  agents: { [agent.name]: agent },
58
+ modelTiers: {},
59
+ modelTiersFrom: {},
58
60
  paths,
59
61
  solo: "call",
60
62
  };
package/dist/config.d.ts CHANGED
@@ -33,6 +33,18 @@ export interface ResolvedAgent {
33
33
  }[];
34
34
  /** Where each field's value came from (for `validate` output). */
35
35
  sources: Record<string, Source>;
36
+ /** Spec 24: the tiers this agent can run on (its runtime's ladder up to and including `model`); unset when nothing is below `model`. */
37
+ tiers?: string[];
38
+ /** Set when the run bound this agent as an instance of a role that has several (spec 23): which role, and the instance number. */
39
+ instance?: {
40
+ role: string;
41
+ k: number;
42
+ };
43
+ /** Instances 2 and up: the role's global/project memory, which only instance 1 may write. */
44
+ sharedMemory?: {
45
+ global?: string;
46
+ project?: string;
47
+ };
36
48
  }
37
49
  export interface ResolvedProject {
38
50
  home: string;
@@ -42,6 +54,10 @@ export interface ResolvedProject {
42
54
  lead: string;
43
55
  dispatcher: DispatcherSettings;
44
56
  agents: Record<string, ResolvedAgent>;
57
+ /** Spec 24: non-empty model ladders per runtime, lowest first (project.yaml replaces team.yaml's per runtime). */
58
+ modelTiers: Partial<Record<Runtime, string[]>>;
59
+ /** Which file each ladder in `modelTiers` came from. */
60
+ modelTiersFrom: Partial<Record<Runtime, "global" | "project">>;
45
61
  paths: ProjectPaths;
46
62
  /** Removed settings still present in config files (ignored); `validate` warns once per file. */
47
63
  ignoredSettings?: {
@@ -52,7 +68,10 @@ export interface ResolvedProject {
52
68
  run?: {
53
69
  id: string;
54
70
  dir: string;
55
- layout: "run" | "legacy";
71
+ layout: "run" | "legacy"; /** Set by the run when same-role instances are possible: the lead's prompt then explains `parallel: true`. */
72
+ instances?: {
73
+ max: number;
74
+ };
56
75
  };
57
76
  /** Agents working in their own git worktree instead of `dir` (set by the dispatcher for the wake-ups in progress). */
58
77
  workspaces?: Record<string, AgentWorkspace>;
@@ -60,6 +79,10 @@ export interface ResolvedProject {
60
79
  solo?: "run" | "call";
61
80
  }
62
81
  export declare function loadGlobal(home: string): {
82
+ model_tiers?: {
83
+ "claude-code"?: string[] | undefined;
84
+ codex?: string[] | undefined;
85
+ } | undefined;
63
86
  agents: Record<string, {
64
87
  runtime?: "claude-code" | "codex" | undefined;
65
88
  model?: string | undefined;
@@ -87,6 +110,10 @@ export declare function loadProjectRaw(home: string, name: string): {
87
110
  team: {
88
111
  lead: string;
89
112
  };
113
+ model_tiers?: {
114
+ "claude-code"?: string[] | undefined;
115
+ codex?: string[] | undefined;
116
+ } | undefined;
90
117
  dispatcher: {
91
118
  max_rounds?: number | undefined;
92
119
  max_parallel?: unknown;
@@ -102,6 +129,7 @@ export declare function loadProjectRaw(home: string, name: string): {
102
129
  effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
103
130
  }[] | undefined;
104
131
  fallback_cooldown_sec?: number | undefined;
132
+ max_instances?: number | undefined;
105
133
  };
106
134
  agents: Record<string, {
107
135
  runtime?: "claude-code" | "codex" | undefined;
package/dist/config.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { absPath, homePaths, projectPaths, } from "./paths.js";
5
- import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
4
+ import { absPath, escapesParent, homePaths, projectPaths, } from "./paths.js";
5
+ import { availableTiers } from "./tiers.js";
6
+ import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
6
7
  export class ConfigError extends Error {
7
8
  }
8
9
  function readYaml(file, schema, label) {
@@ -60,6 +61,16 @@ export function resolveProjectFrom(home, name, g, p) {
60
61
  const lead = p.team.lead;
61
62
  const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
62
63
  const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
64
+ const modelTiers = {};
65
+ const modelTiersFrom = {};
66
+ for (const rt of RUNTIMES) {
67
+ const fromProject = p.model_tiers?.[rt] !== undefined;
68
+ const ladder = fromProject ? p.model_tiers?.[rt] : g.model_tiers?.[rt];
69
+ if (ladder?.length) {
70
+ modelTiers[rt] = [...ladder];
71
+ modelTiersFrom[rt] = fromProject ? "project" : "global";
72
+ }
73
+ }
63
74
  const agents = {};
64
75
  for (const agentName of Object.keys(p.agents)) {
65
76
  const gl = resolveLayer(g.agents[agentName], home);
@@ -144,6 +155,7 @@ export function resolveProjectFrom(home, name, g, p) {
144
155
  }
145
156
  }
146
157
  const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
158
+ const tiers = runtime ? availableTiers(modelTiers[runtime], model) : undefined;
147
159
  const isLead = agentName === lead;
148
160
  const resume = pick("resume");
149
161
  const canMessage = pick("can_message");
@@ -175,6 +187,7 @@ export function resolveProjectFrom(home, name, g, p) {
175
187
  fallbackShared,
176
188
  fallbackInvalid,
177
189
  sources,
190
+ ...(tiers ? { tiers } : {}),
178
191
  };
179
192
  }
180
193
  return {
@@ -184,6 +197,8 @@ export function resolveProjectFrom(home, name, g, p) {
184
197
  lead,
185
198
  dispatcher,
186
199
  agents,
200
+ modelTiers,
201
+ modelTiersFrom,
187
202
  paths,
188
203
  ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
189
204
  };
@@ -252,7 +267,7 @@ export function findProjectForCwd(home, cwd) {
252
267
  let best;
253
268
  for (const pr of listProjects(home)) {
254
269
  const rel = path.relative(pr.dir, cwd);
255
- if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
270
+ if (!escapesParent(rel)) {
256
271
  if (!best || pr.dir.length > best.len)
257
272
  best = { name: pr.name, len: pr.dir.length };
258
273
  }
@@ -276,6 +291,13 @@ export function resolveProjectWithSources(home, name) {
276
291
  continue;
277
292
  sources[`dispatcher.${k}`] = raw.dispatcher[k] !== undefined ? { file: projectFile, key: `dispatcher.${k}` } : { default: true };
278
293
  }
294
+ if (raw.dispatcher.max_instances !== undefined)
295
+ sources["dispatcher.max_instances"] = { file: projectFile, key: "dispatcher.max_instances" };
296
+ for (const rt of RUNTIMES) {
297
+ const from = project.modelTiersFrom[rt];
298
+ if (from)
299
+ sources[`model_tiers.${rt}`] = { file: from === "project" ? projectFile : globalFile, key: `model_tiers.${rt}` };
300
+ }
279
301
  for (const a of Object.values(project.agents)) {
280
302
  const at = (src, field) => {
281
303
  const key = `agents.${a.name}.${field}`;
@@ -305,6 +327,10 @@ export function resolveProjectWithSources(home, name) {
305
327
  else
306
328
  sources[`agents.${a.name}.${f}`] = at(src, f);
307
329
  }
330
+ // The ladder is the runtime's, so its source is the ladder's own key (spec 24.9).
331
+ const ladderSrc = a.tiers ? sources[`model_tiers.${a.runtime}`] : undefined;
332
+ if (ladderSrc)
333
+ sources[`agents.${a.name}.model_tiers`] = ladderSrc;
308
334
  }
309
335
  return { project, sources };
310
336
  }
@@ -312,6 +338,9 @@ export function resolveProjectWithSources(home, name) {
312
338
  export function flattenResolved(project) {
313
339
  const v = { dir: project.dir, "team.lead": project.lead };
314
340
  const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
341
+ for (const rt of RUNTIMES)
342
+ if (project.modelTiers[rt])
343
+ v[`model_tiers.${rt}`] = project.modelTiers[rt];
315
344
  for (const [k, val] of Object.entries(project.dispatcher)) {
316
345
  if (k === "fallback_cooldown_sec" && !anyFallback)
317
346
  continue; // keep `doctor --resolved` unchanged without fallback
@@ -332,6 +361,8 @@ export function flattenResolved(project) {
332
361
  v[`${p}.allow_web`] = a.allowWeb;
333
362
  if (a.fallback.length)
334
363
  v[`${p}.fallback`] = a.fallback;
364
+ if (a.tiers)
365
+ v[`${p}.model_tiers`] = a.tiers;
335
366
  }
336
367
  return v;
337
368
  }
@@ -0,0 +1,4 @@
1
+ import type { DoctorCheck } from "./doctor.js";
2
+ import type { ResolvedProject } from "./config.js";
3
+ /** Same-role instances (spec 23.13): what doctor says about them. Reads configuration and paths only. */
4
+ export declare function diagnoseInstances(project: ResolvedProject, blocker: string | undefined, platform?: NodeJS.Platform): DoctorCheck[];
@@ -0,0 +1,63 @@
1
+ import path from "node:path";
2
+ import { DEFAULT_MAX_INSTANCES, instanceCap } from "./instances.js";
3
+ import { NAME_RE, canonicalPath, isInside } from "./paths.js";
4
+ import { osSandboxAvailable } from "./policy.js";
5
+ import { git } from "./worktree.js";
6
+ /** Same-role instances (spec 23.13): what doctor says about them. Reads configuration and paths only. */
7
+ export function diagnoseInstances(project, blocker, platform = process.platform) {
8
+ const checks = [];
9
+ const written = project.dispatcher.max_instances;
10
+ const max = written ?? DEFAULT_MAX_INSTANCES;
11
+ const add = (level, message) => checks.push({ level, subject: "instances", message });
12
+ if (written !== undefined && written > 1 && blocker) {
13
+ add("warn", `dispatcher.max_instances is ${written}, but runs here take turns in the checkout (${blocker}): parallel tasks run one at a time.`);
14
+ }
15
+ if (blocker || max <= 1 || project.solo)
16
+ return checks;
17
+ const asRun = { ...project, run: { id: "doctor", dir: "", layout: "run" } };
18
+ const members = Object.keys(project.agents).filter((n) => n !== project.lead);
19
+ const usable = [];
20
+ for (const role of members) {
21
+ if (!NAME_RE.test(role)) {
22
+ add("info", `"${role}" cannot run as several instances (name is not a valid branch name).`);
23
+ continue;
24
+ }
25
+ const cap = instanceCap(asRun, "worktree", role);
26
+ if (cap.n < max && cap.reason)
27
+ add("warn", cap.reason);
28
+ if (cap.n > 1)
29
+ usable.push(role);
30
+ }
31
+ if (!osSandboxAvailable()) {
32
+ for (const role of usable) {
33
+ if (project.agents[role]?.runtime !== "codex")
34
+ continue;
35
+ add(written !== undefined ? "warn" : "info", `only "${role}" writes its memory; its other instances are only told not to (Codex without an OS sandbox).`);
36
+ }
37
+ }
38
+ // The shared memory must not contain, or lie inside, a folder an extra instance writes: that is under the run data roots.
39
+ const roots = [project.paths.taskMemory, project.paths.runs, path.join(project.paths.root, "worktrees")];
40
+ try {
41
+ roots.push(git(project.dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"]));
42
+ }
43
+ catch {
44
+ /* not readable: the other roots still count */
45
+ }
46
+ const ci = platform === "darwin";
47
+ const norm = (p) => (ci ? canonicalPath(p).toLowerCase() : canonicalPath(p));
48
+ for (const role of usable) {
49
+ const a = project.agents[role];
50
+ for (const s of [a?.memory.global, a?.memory.project]) {
51
+ if (!s)
52
+ continue;
53
+ const cs = norm(s);
54
+ const contains = roots.find((r) => isInside(cs, norm(r)));
55
+ const inside = roots.find((r) => isInside(norm(r), cs));
56
+ if (contains)
57
+ add("warn", `"${role}" runs one task at a time: its memory ${s} contains ${contains}, where its other instances would write. Move the memory elsewhere to allow parallel tasks.`);
58
+ else if (inside)
59
+ add("warn", `the memory of "${role}" (${s}) is inside ${inside}, where instances write; this may prevent extra instances for some runs. Move the memory elsewhere to be sure parallel tasks are possible.`);
60
+ }
61
+ }
62
+ return checks;
63
+ }
package/dist/doctor.d.ts CHANGED
@@ -44,3 +44,5 @@ export declare function preflightRuntimes(project: ResolvedProject, opts?: Probe
44
44
  /** Everything `agent-lyceum doctor` reports. Reads configuration and asks the CLIs for `--version`/`--help`; never runs an agent. */
45
45
  export declare function diagnoseProject(project: ResolvedProject, opts?: ProbeOptions): Promise<DoctorReport>;
46
46
  export declare function formatDoctor(r: DoctorReport): string;
47
+ /** One-line summary counted from the checks; `info` and `ok` never count. */
48
+ export declare function doctorFooter(r: DoctorReport): string;
package/dist/doctor.js CHANGED
@@ -7,6 +7,7 @@ import { listRecords, RECORD_KINDS } from "./records.js";
7
7
  import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
8
8
  import { runInvocation } from "./process-runner.js";
9
9
  import { validateProject } from "./validate.js";
10
+ import { diagnoseInstances } from "./doctor-instances.js";
10
11
  import { dirtyPaths, git, isolationBlocker } from "./worktree.js";
11
12
  const BINARY = { "claude-code": "claude", codex: "codex" };
12
13
  /** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
@@ -138,6 +139,8 @@ export async function diagnoseProject(project, opts = {}) {
138
139
  : { level: "ok", subject: "git", message: "git repo with a commit: runs and agents work in their own worktrees." });
139
140
  }
140
141
  }
142
+ if (fs.existsSync(project.dir))
143
+ checks.push(...diagnoseInstances(project, blocker));
141
144
  if (fs.existsSync(project.dir) && !blocker) {
142
145
  const missing = [];
143
146
  if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
@@ -195,6 +198,19 @@ export function formatDoctor(r) {
195
198
  for (const c of r.runtimes) {
196
199
  lines.push(` ${c.runtime}: ${c.binary === "missing" ? "not installed" : `${c.version ?? "version unknown"}; json ${c.json}, resume ${c.resume}, sandbox ${c.sandbox}, effort ${c.effort}`}`);
197
200
  }
198
- lines.push("", r.ok ? "No problems found." : "Problems found.");
201
+ lines.push("", doctorFooter(r));
199
202
  return lines.join("\n");
200
203
  }
204
+ /** One-line summary counted from the checks; `info` and `ok` never count. */
205
+ export function doctorFooter(r) {
206
+ const errors = r.checks.filter((c) => c.level === "error").length;
207
+ const warns = r.checks.filter((c) => c.level === "warn").length;
208
+ const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
209
+ if (errors > 0) {
210
+ const parts = [plural(errors, "error"), ...(warns > 0 ? [plural(warns, "warning")] : [])];
211
+ return `Problems found: ${parts.join(", ")} (marked ERROR${warns > 0 ? " / warn" : ""} above).`;
212
+ }
213
+ if (warns > 0)
214
+ return `No errors; ${plural(warns, "warning")} to review (marked warn above).`;
215
+ return "No problems found.";
216
+ }
@@ -0,0 +1,9 @@
1
+ /** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
2
+ export declare const MAX_DUPLICATE_NOTICES = 2;
3
+ /** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
4
+ export declare function duplicateNoticeKey(sender: string, repeats: Iterable<[string, string]>): string;
5
+ /**
6
+ * Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
7
+ * are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
8
+ */
9
+ export declare function shouldNotifyDuplicate(runDir: string, key: string, source: string): boolean;
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { atomicWrite } from "./fs-util.js";
4
+ /** How many distinct sent-again mails of one repeat are announced to the sender before further repeats are set aside silently. */
5
+ export const MAX_DUPLICATE_NOTICES = 2;
6
+ const FILE = "duplicate-notices.json";
7
+ /** Key of one sender's repeat of earlier mail; JSON keeps it unambiguous whatever the names contain. */
8
+ export function duplicateNoticeKey(sender, repeats) {
9
+ return JSON.stringify([sender, [...repeats].map(([to, id]) => [to, id]).sort()]);
10
+ }
11
+ function load(runDir) {
12
+ try {
13
+ const data = JSON.parse(fs.readFileSync(path.join(runDir, FILE), "utf8"));
14
+ return data && typeof data === "object" && !Array.isArray(data) ? data : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ /**
21
+ * Decide whether the repeat found in outbox mail `source` is announced. Idempotent per source: the notified source ids
22
+ * are stored, so a replay after a crash neither counts twice nor loses a notice it already earned.
23
+ */
24
+ export function shouldNotifyDuplicate(runDir, key, source) {
25
+ const all = load(runDir);
26
+ const seen = Array.isArray(all[key]) ? all[key] : [];
27
+ if (seen.includes(source))
28
+ return true;
29
+ if (seen.length >= MAX_DUPLICATE_NOTICES)
30
+ return false;
31
+ all[key] = [...seen, source];
32
+ atomicWrite(path.join(runDir, FILE), `${JSON.stringify(all, null, 2)}\n`);
33
+ return true;
34
+ }
@@ -7,6 +7,8 @@ export interface Candidate {
7
7
  model?: string;
8
8
  effort?: Effort;
9
9
  primary: boolean;
10
+ /** The cheaper model a lead's `tier` picked (spec 24.7): not the primary, and not a fallback either. */
11
+ tiered?: true;
10
12
  /** `<runtime>/<model>`; the cooldown and session key. */
11
13
  key: string;
12
14
  }
@@ -14,7 +16,7 @@ export interface Candidate {
14
16
  * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
15
17
  * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
16
18
  */
17
- export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
19
+ export declare function candidatesFor(agent: ResolvedAgent, tierModel?: string): Candidate[];
18
20
  /**
19
21
  * The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
20
22
  * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
@@ -39,7 +41,7 @@ export declare class Cooldowns {
39
41
  }>): Cooldowns;
40
42
  }
41
43
  /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
42
- export declare function planAttempts(agent: ResolvedAgent, cooldowns: Cooldowns, now: number): Candidate[];
44
+ export declare function planAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
43
45
  export type Step = "retry-same" | "next-candidate" | "stop";
44
46
  /**
45
47
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
package/dist/fallback.js CHANGED
@@ -4,12 +4,18 @@ const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase
4
4
  * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
5
5
  * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
6
6
  */
7
- export function candidatesFor(agent) {
7
+ export function candidatesFor(agent, tierModel) {
8
8
  // `validate` refuses an agent without a runtime before anything is woken.
9
9
  const runtime = agent.runtime;
10
10
  const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
11
11
  const out = [primary];
12
12
  const seen = new Set([primary.key]);
13
+ // A tier's cheaper model goes first (spec 24.7.1); one that is the ceiling itself adds nothing.
14
+ if (tierModel && keyOf(runtime, tierModel) !== primary.key) {
15
+ const key = keyOf(runtime, tierModel);
16
+ out.unshift({ runtime, model: tierModel, effort: agent.effort, primary: false, tiered: true, key });
17
+ seen.add(key);
18
+ }
13
19
  for (const f of agent.fallback) {
14
20
  const model = f.model || undefined;
15
21
  const key = keyOf(f.runtime, model);
@@ -28,6 +34,8 @@ export function candidatesFor(agent) {
28
34
  export function agentForCandidate(agent, c) {
29
35
  if (c.primary)
30
36
  return agent;
37
+ if (c.tiered)
38
+ return { ...agent, model: c.model };
31
39
  const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
32
40
  return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
33
41
  }
@@ -63,10 +71,9 @@ export class Cooldowns {
63
71
  }
64
72
  }
65
73
  /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
66
- export function planAttempts(agent, cooldowns, now) {
67
- const all = candidatesFor(agent);
74
+ export function planAttempts(all, cooldowns, now) {
68
75
  const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
69
- return warm.length ? warm : all;
76
+ return warm.length ? warm : [...all];
70
77
  }
71
78
  /**
72
79
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
package/dist/guard.d.ts CHANGED
@@ -9,6 +9,12 @@ export interface Violation {
9
9
  /** The kept copy is cut to MAX_KEEP_BYTES. */
10
10
  truncated?: boolean;
11
11
  }
12
+ /** A change to a cross-run shared file that no agent of the batch may make, while another run of the project was live. */
13
+ export interface Unattributed {
14
+ file: string;
15
+ agents: string[];
16
+ otherRuns: string[];
17
+ }
12
18
  export declare const MAX_KEEP_BYTES: number;
13
19
  /**
14
20
  * Detects (and reverts) changes to protected files made by agents that may not edit them.
@@ -16,10 +22,20 @@ export declare const MAX_KEEP_BYTES: number;
16
22
  */
17
23
  export declare class ProtectedGuard {
18
24
  private project;
25
+ private otherLiveRuns;
19
26
  private baseline;
20
27
  /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
21
28
  private records;
22
- constructor(project: ResolvedProject);
29
+ /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
30
+ private unattributed;
31
+ /** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
32
+ private liveAtBase;
33
+ /** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
34
+ constructor(project: ResolvedProject, otherLiveRuns?: () => string[]);
35
+ /** The unattributed changes since the last call (they were kept and adopted as the baseline). */
36
+ takeUnattributed(): Unattributed[];
37
+ /** Files shared by every run of the project: only these can be changed by another run's lead. */
38
+ private crossRunShared;
23
39
  /**
24
40
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
25
41
  * judged: edits made in between (you, or another run of the project) are not the batch's doing.
package/dist/guard.js CHANGED
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readRegularFileNoFollow } from "./fs-util.js";
5
+ import { escapesParent } from "./paths.js";
5
6
  import { mayEditProtected, recordsDir, repoInstructionFiles, sharedContextFiles, sharedIndex } from "./policy.js";
6
7
  export const MAX_KEEP_BYTES = 1024 * 1024;
7
8
  function safeName(file) {
@@ -13,18 +14,38 @@ function safeName(file) {
13
14
  */
14
15
  export class ProtectedGuard {
15
16
  project;
17
+ otherLiveRuns;
16
18
  baseline = new Map();
17
19
  /** Regular files of the records tree (including `.archive/`); only the lead may change them. */
18
20
  records = new Map();
19
- constructor(project) {
21
+ /** Changes kept (not reverted) because another live run may have made them; see `takeUnattributed`. */
22
+ unattributed = [];
23
+ /** Other runs that were live at the last `rebase()` (or at `check()` time); computed once per batch. */
24
+ liveAtBase = [];
25
+ /** `otherLiveRuns`: ids of the project's other runs that have a live lock holder right now. */
26
+ constructor(project, otherLiveRuns = () => []) {
20
27
  this.project = project;
28
+ this.otherLiveRuns = otherLiveRuns;
21
29
  this.rebase();
22
30
  }
31
+ /** The unattributed changes since the last call (they were kept and adopted as the baseline). */
32
+ takeUnattributed() {
33
+ const out = this.unattributed;
34
+ this.unattributed = [];
35
+ return out;
36
+ }
37
+ /** Files shared by every run of the project: only these can be changed by another run's lead. */
38
+ crossRunShared(file) {
39
+ if (sharedContextFiles(this.project).includes(file))
40
+ return true;
41
+ return Object.values(this.project.agents).some((a) => a.agentMd === file);
42
+ }
23
43
  /**
24
44
  * Take the current files as the baseline. Called right before a batch is woken, so only what changes while it runs is
25
45
  * judged: edits made in between (you, or another run of the project) are not the batch's doing.
26
46
  */
27
47
  rebase() {
48
+ this.liveAtBase = this.otherLiveRuns();
28
49
  for (const f of ProtectedGuard.protectedFiles(this.project))
29
50
  this.baseline.set(f, read(f));
30
51
  this.records = readTree(recordsDir(this.project));
@@ -69,6 +90,7 @@ export class ProtectedGuard {
69
90
  /** Compare against the baseline after `running` agents finished; revert unauthorized changes. */
70
91
  check(running) {
71
92
  const violations = [];
93
+ const other = [...new Set([...this.liveAtBase, ...this.otherLiveRuns()])];
72
94
  for (const [file, before] of this.baseline) {
73
95
  const now = read(file);
74
96
  if (same(before, now))
@@ -78,6 +100,11 @@ export class ProtectedGuard {
78
100
  this.baseline.set(file, now);
79
101
  continue;
80
102
  }
103
+ if (other.length && this.crossRunShared(file)) {
104
+ this.baseline.set(file, now);
105
+ this.unattributed.push({ file, agents: running.map((a) => a.name), otherRuns: other });
106
+ continue;
107
+ }
81
108
  if (before === null) {
82
109
  const kept = this.keep(file, now);
83
110
  fs.rmSync(file, { force: true });
@@ -90,10 +117,10 @@ export class ProtectedGuard {
90
117
  violations.push({ file, action: "restored", suspects: running.map((a) => a.name), ...kept });
91
118
  }
92
119
  }
93
- this.checkRecords(running, violations);
120
+ this.checkRecords(running, violations, other);
94
121
  return violations;
95
122
  }
96
- checkRecords(running, violations) {
123
+ checkRecords(running, violations, other) {
97
124
  const root = recordsDir(this.project);
98
125
  // Never write through a symlinked parent of the records tree: skip its protection for this batch.
99
126
  if (isLink(path.dirname(root)) || isLink(path.dirname(path.dirname(root))))
@@ -109,6 +136,10 @@ export class ProtectedGuard {
109
136
  continue;
110
137
  if (lead)
111
138
  continue;
139
+ if (other.length) {
140
+ this.unattributed.push({ file, agents: suspects, otherRuns: other });
141
+ continue;
142
+ }
112
143
  const kept = this.keep(file, cur);
113
144
  if (before === null) {
114
145
  if (!insideRoot(root, path.dirname(file)))
@@ -124,7 +155,7 @@ export class ProtectedGuard {
124
155
  violations.push({ file, action: "restored", suspects, ...kept });
125
156
  }
126
157
  }
127
- if (lead)
158
+ if (lead || other.length)
128
159
  this.records = now;
129
160
  }
130
161
  /**
@@ -133,7 +164,7 @@ export class ProtectedGuard {
133
164
  */
134
165
  realDirs(root, dir, done, suspects, violations) {
135
166
  const rel = path.relative(root, dir);
136
- if (rel.startsWith("..") || path.isAbsolute(rel))
167
+ if (escapesParent(rel))
137
168
  return false;
138
169
  let cur = root;
139
170
  for (const part of ["", ...(rel ? rel.split(path.sep) : [])]) {
@@ -167,7 +198,7 @@ function isLink(p) {
167
198
  function insideRoot(root, dir) {
168
199
  try {
169
200
  const r = path.relative(fs.realpathSync(root), fs.realpathSync(dir));
170
- return !r.startsWith("..") && !path.isAbsolute(r);
201
+ return !escapesParent(r);
171
202
  }
172
203
  catch {
173
204
  return false;
@@ -0,0 +1,15 @@
1
+ import type { ResolvedProject } from "./config.js";
2
+ import { type MailLayout } from "./run-store.js";
3
+ import { type AgentWorkspace } from "./worktree.js";
4
+ export interface InstanceContainment {
5
+ /** Why instance `k` of `role` must not exist (it could write the role's shared memory), judged on the paths it will get; undefined when fine. */
6
+ check: (role: string, k: number) => string | undefined;
7
+ /** The same judgment with the worktree it actually got (root and git dir). */
8
+ checkWorkspace: (role: string, k: number, ws: Pick<AgentWorkspace, "root" | "gitDir">) => string | undefined;
9
+ }
10
+ /**
11
+ * D-17 as one object per run: reads the file system only through `canonicalPath` (lstat/realpath), writes nothing. The shared memory of a role
12
+ * is its `memory.global`/`memory.project`; an instance k >= 2 may write its task memory, outbox, worktree and git dir, none of which may
13
+ * contain or lie inside the shared memory (the OS sandbox and the Codex writable roots are built from the same paths).
14
+ */
15
+ export declare function instanceContainment(base: ResolvedProject, runDir: string, layout: MailLayout, platform?: NodeJS.Platform): InstanceContainment;