agent-lyceum 0.26.0 → 0.28.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.
@@ -1,9 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { invokeWithSandboxChecks } from "./sandbox-invocation.js";
3
4
  import { realInvoker } from "./adapters/index.js";
4
- import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, planAttempts, runWithFallback } from "./fallback.js";
5
+ import { agentForCandidate, candidatesFor, Cooldowns, defaultSleep, deferredAttempts, planAttempts, runWithFallback } from "./fallback.js";
5
6
  import { briefOf, digestOf, doneContract, mergeSteps, parseSteps } from "./format.js";
6
7
  import { ProtectedGuard } from "./guard.js";
8
+ import { listRunLocks, lockHolderAlive } from "./project-lock.js";
7
9
  import { addBatch, askersWithAnswers, askReplyPath, checkAnswers, describeCheck, isComplete, markDelivered, parseAskBody, readAskReply, renderAnswers, resolveOptionLetters, writeAskReply } from "./ask-reply.js";
8
10
  import { deliver, deliverOnce, ensureAgentDirs, ensureProjectDirs, finishDone, listUnread, PENDING_DIR, readMessageNoFollow, rejectDone, routeOutboxes, unreadFiles } from "./mailbox.js";
9
11
  import { instanceContainment } from "./instance-containment.js";
@@ -20,6 +22,7 @@ import { bindRunProject, newRunId, newRunState, roundLimit, roundsLabel, saveRun
20
22
  import { soloProject } from "./solo.js";
21
23
  import { taskMessageBody } from "./task.js";
22
24
  import { must } from "./assert.js";
25
+ import { modelForTier, wakeTier } from "./tiers.js";
23
26
  import { openPullRequest, prBody, prTitle, realRunner } from "./open-pr.js";
24
27
  export { newRunId };
25
28
  export const RESULT_FILE = "result.md";
@@ -37,6 +40,8 @@ function readIndex(file) {
37
40
  }
38
41
  /** The `state.sessions` key of the session an agent runs in on candidate `c`: a backup has a session of its own. */
39
42
  const sessionKeyOf = (agent, c) => (c.primary ? agent.name : `${agent.name}@${c.key}`);
43
+ /** Why `key` is cooling down, for a message. */
44
+ const cooldownKind = (cd, key) => cd.toJSON()[key]?.kind ?? "unknown";
40
45
  /** `runtime/model`, or the runtime alone for its default model. */
41
46
  const modelLabel = (c) => (c.model ? `${c.runtime}/${c.model}` : c.runtime);
42
47
  /** Each model given up on once, in order, with its last failure (retries on the same model are one entry). */
@@ -241,7 +246,8 @@ export class RunSession {
241
246
  }
242
247
  const saveState = () => saveRunState(runDir, state);
243
248
  // Concurrent runs: the repo's instruction files the run can touch are the lead worktree's copies; your own tree is never watched (or restored).
244
- const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project);
249
+ const otherLiveRuns = () => listRunLocks(project.paths.root).filter((l) => l.run_id !== runId && lockHolderAlive(l)).map((l) => l.run_id);
250
+ const guard = new ProtectedGuard(leadSpace ? { ...project, workspaces: { ...project.workspaces, [project.lead]: leadSpace } } : project, otherLiveRuns);
245
251
  if (resume) {
246
252
  log("resume", { rounds: state.rounds, interrupted: Object.keys(resume.active ?? {}) });
247
253
  if (opts.followup) {
@@ -320,6 +326,21 @@ export class RunSession {
320
326
  log("note", { text });
321
327
  say(` ${text}`);
322
328
  }
329
+ /** Spec 24.5.2: log every tier that was dropped; tell the sender once per reason (`no-tiers` is log-only). */
330
+ noteTierIgnored(route) {
331
+ for (const t of route.tierIgnored) {
332
+ this.log("tier_ignored", { from: t.from, id: t.id, to: t.to, reason: t.reason });
333
+ if (t.reason === "no-tiers")
334
+ continue;
335
+ const key = `tier\u0000${t.from}\u0000${t.reason}`;
336
+ if (this.ignoredNoted.has(key))
337
+ continue;
338
+ this.ignoredNoted.add(key);
339
+ this.note(t.reason === "not-lead"
340
+ ? `${t.from}'s mail ${t.id} has \`tier\`, but only the lead (${this.project.lead}) can choose a member's tier: it was delivered without one.`
341
+ : `${t.from}'s mail ${t.id} ("${t.subject}") has \`tier\` but is a ${t.type}: tiers apply to tasks only, so it was delivered without one.`);
342
+ }
343
+ }
323
344
  /** Bind the project to this run again, after its slot table changed. */
324
345
  rebind() {
325
346
  this.project = withCapability(bindRunProject(this.base, this.runDir, this.layout, this.state.instances), this.wsMode, this.instanceCheck, this.state.instances);
@@ -555,15 +576,28 @@ export class RunSession {
555
576
  timeoutSec: cfg.wake_timeout_sec,
556
577
  };
557
578
  // Without a fallback chain (one candidate) the loop, its output and its records are exactly those of plain retries (spec 20.2 decision 11).
558
- const all = candidatesFor(agent);
579
+ // A lead's `tier` (spec 24.5.3): only for an agent with a ladder, and only a valid value counts.
580
+ const tier = agent.tiers && agent.tiers.length >= 2 ? wakeTier(unread.map((m) => m.meta.tier)) : undefined;
581
+ const all = candidatesFor(agent, tier && agent.tiers ? modelForTier(agent.tiers, tier) : undefined);
559
582
  const chain = all.length > 1;
560
- const candidates = chain ? planAttempts(agent, cooldowns, Date.now()) : all;
583
+ const candidates = chain ? planAttempts(all, cooldowns, Date.now()) : all;
584
+ // Models cooling down at the start are not dropped for good: when the warm ones fail they are tried last, once recovered.
585
+ const deferred = chain && candidates.length < all.length ? deferredAttempts(all, cooldowns, Date.now()).filter((c) => !candidates.includes(c)) : [];
586
+ const ceiling = must(all.find((c) => c.primary), "the agent's own model");
587
+ const tieredFirst = all[0]?.tiered ? all[0] : undefined;
561
588
  // The model a backup's calls stand in for: the one switched away from in this wake, or the cooling-down primary it started past.
562
- let replaced = candidates[0]?.primary ? undefined : all[0];
589
+ // A tier's cheaper model is not a fallback (spec 24.7.3): only chain entries have one.
590
+ let replaced = candidates[0]?.primary || candidates[0]?.tiered ? undefined : ceiling;
591
+ if (tieredFirst && !candidates[0]?.tiered) {
592
+ const left = Math.ceil(cooldowns.active(tieredFirst.key, Date.now()) / 1000);
593
+ say(` ${agent.name} tier ${tier} model ${modelLabel(tieredFirst)} is cooling down (${cooldownKind(cooldowns, tieredFirst.key)}, ${left}s left): starting on ${modelLabel(candidates[0])}`);
594
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(tieredFirst), to: modelLabel(candidates[0]), reason: "cooldown" });
595
+ }
563
596
  let calls = 0;
564
597
  let stoppedByCancel = false;
565
598
  const outcome = await runWithFallback({
566
599
  candidates,
600
+ deferred,
567
601
  legacy: !chain,
568
602
  retry: cfg.retry,
569
603
  cooldowns,
@@ -579,9 +613,26 @@ export class RunSession {
579
613
  // The round limit (when one is set) ends the run; this message stays unread for the resume.
580
614
  return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
581
615
  },
616
+ onCooldownWait: (c, ms) => {
617
+ if (opts.signal?.aborted)
618
+ return;
619
+ say(` ${agent.name}: no other model is available; waiting ${Math.ceil(ms / 1000)}s for ${modelLabel(c)} to recover`);
620
+ log("cooldown_wait", { agent: agent.name, model: modelLabel(c), wait_ms: ms });
621
+ },
582
622
  onSwitch: (from, to, kind) => {
583
- replaced = from;
623
+ if (!from.tiered)
624
+ replaced = from;
584
625
  this.syncCooldowns();
626
+ if (from.tiered) {
627
+ log("tier_step_up", { agent: agent.name, tier, from: modelLabel(from), to: modelLabel(to), reason: kind ?? "unknown" });
628
+ if (opts.signal?.aborted)
629
+ return;
630
+ say(` ${agent.name} stepping up from tier ${tier} (${modelLabel(from)}) to ${modelLabel(to)} (${kind ?? "unknown error"})`);
631
+ const resumesTo = agent.resume && !!state.sessions[sessionKeyOf(agent, to)];
632
+ this.note(`${agent.name}'s tier ${tier} model ${modelLabel(from)} failed (${kind ?? "unknown error"}); it is woken again on ${modelLabel(to)}, which ${resumesTo ? "resumes its own earlier session" : "starts a fresh session"}, and redoes this wake-up. ` +
633
+ `Anything the failed attempts already did outside the mailbox (file edits, commands) was not undone: side effects may have happened.`);
634
+ return;
635
+ }
585
636
  if (opts.signal?.aborted)
586
637
  return;
587
638
  say(` ${agent.name} falling back to ${modelLabel(to)} (${kind ?? "unknown error"})`);
@@ -603,21 +654,21 @@ export class RunSession {
603
654
  const redo = this.redoContext(agent.name, unread.map((m) => m.meta.id), att.id, attempt);
604
655
  const userPrompt = redoUserPrompt(base.userPrompt, { attempt, error: ctx.previous?.error, prior: redo.prior, drafts: redo.drafts, taskMemory: agent.memory.task });
605
656
  const retryN = chain ? ctx.candidateAttempt : attempt;
606
- say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime})${retryN ? ` — retry ${retryN}` : ""}`);
657
+ say(`[round ${roundsLabel(state.rounds, state.max_rounds)}] waking ${agent.name} (${chain ? modelLabel(c) : agent.runtime}${c.tiered ? `, tier ${tier}` : ""})${retryN ? ` — retry ${retryN}` : ""}`);
607
658
  const sessionId = agent.resume ? state.sessions[sessionKey] : undefined;
608
659
  const handling = unread.map((m) => ({ from: m.meta.from, type: m.meta.type, subject: m.meta.subject, brief: briefOf(m.body), digest: digestOf(m.meta.type, m.body) }));
609
660
  // What the panel shows for the message being worked on is uncut; the wake record below keeps the short digest.
610
661
  const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
611
662
  state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
612
663
  this.saveState();
613
- const result = await invoke({
664
+ const result = await invokeWithSandboxChecks(invoke, {
614
665
  ...base,
615
666
  agent: runAs,
616
667
  signal: opts.signal,
617
668
  logDir: attemptLogDir(runDir, att.id),
618
669
  userPrompt,
619
670
  sessionId,
620
- }).catch((e) => ({
671
+ }, agent, { event: (name, fields) => log(name, fields) }, attempt).catch((e) => ({
621
672
  ok: false,
622
673
  text: "",
623
674
  exitCode: null,
@@ -632,7 +683,7 @@ export class RunSession {
632
683
  state.cache_write_tokens += result.cacheWriteTokens ?? 0;
633
684
  delete state.active[agent.name];
634
685
  // With a chain only: which model ran, what it replaced, why it failed.
635
- const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || !replaced ? undefined : modelLabel(replaced), kind: result.kind } : {};
686
+ const which = chain ? { runtime: c.runtime, model: c.model, fallback_from: c.primary || c.tiered ? undefined : modelLabel(replaced ?? ceiling), kind: result.kind } : {};
636
687
  state.wakes.push({
637
688
  round,
638
689
  agent: agent.name,
@@ -645,8 +696,9 @@ export class RunSession {
645
696
  handling,
646
697
  error: result.error,
647
698
  ...which,
699
+ ...(tier ? { tier } : {}),
648
700
  });
649
- state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}) };
701
+ state.last_wake[agent.name] = { at: new Date().toISOString(), ok: result.ok, error: result.error, ...(chain ? { runtime: c.runtime, model: c.model } : {}), ...(tier ? { tier } : {}) };
650
702
  log("wake", {
651
703
  agent: agent.name,
652
704
  runtime: c.runtime,
@@ -659,6 +711,7 @@ export class RunSession {
659
711
  error: result.error,
660
712
  ...(redo.prior.length ? { redo_of: redo.prior.map((a) => a.id) } : {}),
661
713
  ...(chain ? { model: c.model, fallback_from: which.fallback_from, kind: result.kind } : {}),
714
+ ...(tier ? { tier } : {}),
662
715
  });
663
716
  this.saveState();
664
717
  if (result.ok) {
@@ -681,6 +734,10 @@ export class RunSession {
681
734
  fault("park:before-finish");
682
735
  finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
683
736
  say(` ${agent.name} failed: ${result.error ?? "unknown error"}`);
737
+ if (result.kind === "config" && result.error?.startsWith("sandbox refused")) {
738
+ this.note(`${agent.name} attempt ${att.n} was refused before it started (${result.error}); nothing was run, so there are no side effects to undo.`);
739
+ return result;
740
+ }
684
741
  this.note(`${agent.name} attempt ${att.n} failed (${result.error ?? "unknown error"})${parked.length ? `; ${parked.length} output file(s) set aside in ${path.join("mail", "attempts", att.id, "outbox")}` : ""}. ` +
685
742
  `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
686
743
  return result;
@@ -820,6 +877,11 @@ export class RunSession {
820
877
  (v.saved ? `\n\nWhat was written is kept at \`${v.saved}\` (sha256 ${v.sha256}${v.truncated ? ", truncated to 1 MiB" : ""}). If it is worth keeping, have an agent that may edit this file redo it.` : ""),
821
878
  });
822
879
  }
880
+ // Another run's lead edited a cross-run shared file: kept, not a violation of this batch, nobody is woken for it.
881
+ for (const u of guard.takeUnattributed()) {
882
+ say(` shared file changed by another live run (${u.otherRuns.join(", ")}); kept: ${u.file}`);
883
+ log("protected-change-unattributed", { ...u });
884
+ }
823
885
  // Crash seam: every wake of the batch is finished and marked output_ready; nothing of it is integrated or routed yet.
824
886
  fault("wake:ready");
825
887
  batch.forEach((a, i) => {
@@ -851,6 +913,7 @@ export class RunSession {
851
913
  contract = undefined;
852
914
  }
853
915
  }
916
+ this.noteTierIgnored(route);
854
917
  const askAccepted = this.handleAsks(route);
855
918
  for (const d of route.delivered)
856
919
  log("route", d);
@@ -1,6 +1,6 @@
1
1
  import type { ResolvedProject } from "./config.js";
2
2
  import type { DigestSection, Step } from "./format.js";
3
- import { type ErrorKind, type Runtime, type RunOutcome } from "./schema.js";
3
+ import { type ErrorKind, type Runtime, type RunOutcome, type Tier } from "./schema.js";
4
4
  import type { ResultBranch } from "./worktree.js";
5
5
  export declare const STATE_FILE = "state.json";
6
6
  /** The newest state format this version reads. */
@@ -51,6 +51,8 @@ export interface WakeRecord {
51
51
  fallback_from?: string;
52
52
  /** With a fallback chain only: why the call failed. */
53
53
  kind?: ErrorKind;
54
+ /** The tier this wake ran at (spec 24): set only when every mail it handled carried a valid one. */
55
+ tier?: Tier;
54
56
  }
55
57
  /** A model given up on during a wake with a fallback chain, for `failure.tried`. */
56
58
  export interface TriedModel {
@@ -123,6 +125,7 @@ export interface RunState {
123
125
  error?: string;
124
126
  runtime?: Runtime;
125
127
  model?: string;
128
+ tier?: Tier;
126
129
  }>;
127
130
  /** Process running this dispatcher, so a monitor can tell "running" from "interrupted". */
128
131
  pid: number;
@@ -0,0 +1,35 @@
1
+ import type { WakeInput, WakeResult, Invoker } from "./adapters/types.js";
2
+ import type { ResolvedAgent, ResolvedProject } from "./config.js";
3
+ import { type SandboxPathConflict } from "./policy.js";
4
+ /** Where the `sandbox` and `sandbox-refused` events go: the run's log, or stderr for `call` (spec 25.8). */
5
+ export interface SandboxSink {
6
+ event(name: string, fields: Record<string, unknown>): void;
7
+ }
8
+ export interface ProtectedPath {
9
+ path: string;
10
+ reason: string;
11
+ }
12
+ /**
13
+ * Everything a sandbox allowance must not reach (spec 25.4.3), canonical. `deep` adds the per-invocation items: the concrete
14
+ * write policy's deny list, bound workspaces and the git common dir. The agent's own checkout is left out: it is already writable.
15
+ */
16
+ export declare function protectedPathsFor(project: ResolvedProject, agent: ResolvedAgent, deep: boolean): ProtectedPath[];
17
+ export declare function describeConflict(agent: string, c: SandboxPathConflict, when: "load" | "invocation"): string;
18
+ /** Conflicts of one agent's `write`/`unix_sockets` with the protected set. */
19
+ export declare function sandboxConflictsOf(project: ResolvedProject, agent: ResolvedAgent, deep: boolean): SandboxPathConflict[];
20
+ /** Capability excess of each backup of `agent` over its primary (spec 25.9): `[candidate label, excess...]`. */
21
+ export declare function fallbackCapExcess(agent: ResolvedAgent): {
22
+ label: string;
23
+ excess: string[];
24
+ }[];
25
+ /** The refusal, if any, for this invocation. */
26
+ export declare function sandboxRefusal(input: WakeInput, base: ResolvedAgent): {
27
+ reason: string;
28
+ path?: string;
29
+ } | undefined;
30
+ /**
31
+ * The one entry every adapter invocation goes through (spec 25.4.3, 25.19.1): refuses (returning a `config` failure, never
32
+ * throwing, never spawning) when an allowance reaches a protected path or a backup would widen them; otherwise records
33
+ * the `sandbox` event and calls `invoke`. An agent without `sandbox`, and a policy-override wake, go straight through.
34
+ */
35
+ export declare function invokeWithSandboxChecks(invoke: Invoker, input: WakeInput, base: ResolvedAgent, sink: SandboxSink, attempt: number): Promise<WakeResult>;
@@ -0,0 +1,186 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { canonicalPath } from "./paths.js";
6
+ /**
7
+ * `canonicalPath` for the sandbox check: a path that is missing (ENOENT/ENOTDIR) falls back to its nearest existing ancestor as
8
+ * usual, but any other realpath error (EACCES, ELOOP, ...) throws so the invocation is refused (fail closed).
9
+ */
10
+ function strictCanonical(p) {
11
+ for (let head = path.resolve(p);; head = path.dirname(head)) {
12
+ try {
13
+ fs.realpathSync.native(head);
14
+ break;
15
+ }
16
+ catch (e) {
17
+ const code = e.code;
18
+ if (code !== "ENOENT" && code !== "ENOTDIR")
19
+ throw new Error(`cannot resolve ${head} (${code ?? e.message})`);
20
+ if (path.dirname(head) === head)
21
+ break;
22
+ }
23
+ }
24
+ return canonicalPath(p);
25
+ }
26
+ import { inboxDir, memoryDirs, osSandboxAvailable, outboxDir, recordsDir, repoDirFor, repoInstructionFiles, sandboxCapExcess, sandboxCaps, sandboxPathConflicts, sharedContextFiles, writePolicy, } from "./policy.js";
27
+ /** Whether `cwd` or a parent holds a `.git` entry (a directory, or the file of a linked worktree). */
28
+ function insideGitCheckout(cwd) {
29
+ for (let d = path.resolve(cwd);; d = path.dirname(d)) {
30
+ if (fs.existsSync(path.join(d, ".git")))
31
+ return true;
32
+ if (path.dirname(d) === d)
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * `git rev-parse --git-common-dir` of `cwd`, absolute; undefined when `cwd` is not in a git checkout. Fails closed: when it is a
38
+ * checkout but the probe fails, throws, so the invocation is refused instead of running with the shared git dir unprotected.
39
+ */
40
+ function gitCommonDir(cwd) {
41
+ try {
42
+ const out = execFileSync("git", ["rev-parse", "--git-common-dir"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 5000 }).trim();
43
+ if (out)
44
+ return path.resolve(cwd, out);
45
+ }
46
+ catch (e) {
47
+ if (!insideGitCheckout(cwd))
48
+ return undefined;
49
+ throw new Error(`cannot find the git common dir of ${cwd} (git rev-parse --git-common-dir failed: ${e.message.split("\n")[0]})`);
50
+ }
51
+ if (insideGitCheckout(cwd))
52
+ throw new Error(`cannot find the git common dir of ${cwd} (git rev-parse --git-common-dir printed nothing)`);
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Everything a sandbox allowance must not reach (spec 25.4.3), canonical. `deep` adds the per-invocation items: the concrete
57
+ * write policy's deny list, bound workspaces and the git common dir. The agent's own checkout is left out: it is already writable.
58
+ */
59
+ export function protectedPathsFor(project, agent, deep) {
60
+ const out = [];
61
+ const add = (p, reason) => {
62
+ if (p)
63
+ out.push({ path: canonicalPath(p), reason });
64
+ };
65
+ add(project.home, "agent-lyceum home");
66
+ add(project.dir, "project directory");
67
+ add(path.join(os.homedir(), ".claude"), "Claude Code settings");
68
+ add(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "Codex settings");
69
+ for (const a of Object.values(project.agents)) {
70
+ add(a.agentMd, `AGENT.md of ${a.name}`);
71
+ for (const m of memoryDirs(a))
72
+ add(m, `memory of ${a.name}`);
73
+ add(outboxDir(project, a.name), `outbox of ${a.name}`);
74
+ add(inboxDir(project, a.name), `inbox of ${a.name}`);
75
+ }
76
+ for (const f of sharedContextFiles(project))
77
+ add(f, "shared context");
78
+ for (const f of repoInstructionFiles(project))
79
+ add(f, "repo instruction file");
80
+ add(recordsDir(project), "project records");
81
+ if (!deep)
82
+ return out;
83
+ for (const d of writePolicy(project, agent).deny)
84
+ add(d, "write-protected path");
85
+ if (project.run)
86
+ add(project.run.dir, "run directory");
87
+ add(path.join(project.paths.root, "worktrees"), "worktrees root");
88
+ const own = project.workspaces?.[agent.name];
89
+ for (const [name, ws] of Object.entries(project.workspaces ?? {})) {
90
+ if (name === agent.name)
91
+ continue;
92
+ add(ws.root, `workspace of ${name}`);
93
+ add(ws.gitDir, `git dir of ${name}`);
94
+ add(ws.commonDir, "git common dir");
95
+ }
96
+ // The agent's own checkout and git dir are already inside its write scope, so they are not listed; the shared common dir is.
97
+ add(own?.commonDir ?? gitCommonDir(repoDirFor(project, agent.name)), "git common dir");
98
+ return out;
99
+ }
100
+ export function describeConflict(agent, c, when) {
101
+ const suffix = when === "invocation" ? " (checked before invocation)" : "";
102
+ const rel = c.relation === "equal" ? "is" : c.relation === "inside" ? "is inside" : "contains";
103
+ return `agent ${agent}: sandbox.${c.key}[${c.index}] ${c.path} ${rel} protected path ${c.protectedPath} (${c.reason}); pick a different directory.${suffix}`;
104
+ }
105
+ /** Conflicts of one agent's `write`/`unix_sockets` with the protected set. */
106
+ export function sandboxConflictsOf(project, agent, deep) {
107
+ const s = agent.sandbox;
108
+ if (!s)
109
+ return [];
110
+ const entries = [
111
+ ...s.write.map((p, index) => ({ key: "write", index, path: canonicalPath(p) })),
112
+ ...s.unixSockets.map((p, index) => ({ key: "unix_sockets", index, path: canonicalPath(p) })),
113
+ ];
114
+ return sandboxPathConflicts(entries, protectedPathsFor(project, agent, deep));
115
+ }
116
+ /** Capability excess of each backup of `agent` over its primary (spec 25.9): `[candidate label, excess...]`. */
117
+ export function fallbackCapExcess(agent) {
118
+ if (!agent.sandbox || !agent.runtime)
119
+ return [];
120
+ const primary = sandboxCaps(agent, agent.runtime);
121
+ const out = [];
122
+ for (const f of agent.fallback) {
123
+ const excess = sandboxCapExcess(primary, sandboxCaps(agent, f.runtime));
124
+ if (excess.length)
125
+ out.push({ label: `${f.runtime}/${f.model || "(default model)"}`, excess });
126
+ }
127
+ return out;
128
+ }
129
+ /** The refusal, if any, for this invocation. */
130
+ export function sandboxRefusal(input, base) {
131
+ const agent = input.agent;
132
+ if (!agent.sandbox)
133
+ return undefined;
134
+ for (const p of [...agent.sandbox.write, ...agent.sandbox.unixSockets])
135
+ strictCanonical(p);
136
+ const c = sandboxConflictsOf(input.project, agent, true)[0];
137
+ if (c)
138
+ return { reason: describeConflict(agent.name, c, "invocation"), path: c.path };
139
+ const f = fallbackCapExcess(base)[0];
140
+ if (f)
141
+ return { reason: `agent ${base.name}: fallback ${f.label} would apply more sandbox allowance than the primary (${f.excess.join("; ")}) (checked before invocation)` };
142
+ return undefined;
143
+ }
144
+ function applied(agent) {
145
+ const caps = sandboxCaps(agent);
146
+ const osOn = osSandboxAvailable();
147
+ if (!osOn)
148
+ return { write: agent.runtime === "claude-code" ? "tool-rules" : "none", network: "unrestricted", local_binding: "unrestricted", unix_sockets: "unrestricted" };
149
+ return { write: "os", network: caps.network, local_binding: caps.localBinding ? "on" : "off", unix_sockets: caps.unixSockets ? "on" : "off" };
150
+ }
151
+ /**
152
+ * The one entry every adapter invocation goes through (spec 25.4.3, 25.19.1): refuses (returning a `config` failure, never
153
+ * throwing, never spawning) when an allowance reaches a protected path or a backup would widen them; otherwise records
154
+ * the `sandbox` event and calls `invoke`. An agent without `sandbox`, and a policy-override wake, go straight through.
155
+ */
156
+ export async function invokeWithSandboxChecks(invoke, input, base, sink, attempt) {
157
+ const agent = input.agent;
158
+ if (!agent.sandbox || input.policy)
159
+ return invoke(input);
160
+ const refuse = (reason) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: `sandbox refused: ${reason}`, kind: "config" });
161
+ try {
162
+ const bad = sandboxRefusal(input, base);
163
+ if (bad) {
164
+ sink.event("sandbox-refused", { agent: agent.name, runtime: agent.runtime, reason: bad.reason, ...(bad.path ? { path: bad.path } : {}) });
165
+ return refuse(bad.reason);
166
+ }
167
+ const s = agent.sandbox;
168
+ sink.event("sandbox", {
169
+ agent: agent.name,
170
+ runtime: agent.runtime,
171
+ model: agent.model,
172
+ attempt,
173
+ write: s.write.map(canonicalPath),
174
+ network: s.network,
175
+ local_binding: s.localBinding,
176
+ unix_sockets: s.unixSockets.map(canonicalPath),
177
+ applied: applied(agent),
178
+ });
179
+ }
180
+ catch (e) {
181
+ const reason = `${agent.name}: sandbox check failed: ${e.message}`;
182
+ sink.event("sandbox-refused", { agent: agent.name, runtime: agent.runtime, reason });
183
+ return refuse(reason);
184
+ }
185
+ return invoke(input);
186
+ }
@@ -1,5 +1,6 @@
1
1
  /** The lead's guidance on `parallel: true` (spec 23.12 point 5). Only `init` writes it, into a new lead AGENT.md. */
2
2
  export declare const LEAD_PARALLEL_HINT = "Mark a task `parallel: true` only when it is independent and touches different files from the other open tasks; list each task's files in `## Scope` first; shared components, types, routes and tokens go first, alone.";
3
+ export declare const GLOBAL_TEAM_YAML = "# Global agent library. Projects pick agents from here by name and may override any field.\n# Relative paths here are resolved against this folder. \"~\" is allowed.\nagents:\n lead:\n runtime: claude-code # claude-code | codex\n agent_md: agents/lead/AGENT.md\n memory: { global: agents/lead/memory }\n # model: opus # most capable model: keep it for the lead\n fe-member:\n runtime: claude-code\n agent_md: agents/fe-member/AGENT.md\n memory: { global: agents/fe-member/memory }\n # model: sonnet # cheaper model for routine work\n qa-member:\n runtime: claude-code\n agent_md: agents/qa-member/AGENT.md\n memory: { global: agents/qa-member/memory }\n # model: sonnet # cheaper model for routine work\n # Model hints (comments only):\n # - a read-only search/explorer role suits \"model: haiku\", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).\n # - a reviewer can run on the other runtime (\"runtime: codex\"); leave the codex model unset to use the CLI default.\n";
3
4
  export interface InitResult {
4
5
  home: string;
5
6
  created: string[];
package/dist/scaffold.js CHANGED
@@ -33,21 +33,27 @@ You verify work: run and write tests, reproduce bugs, and review changes against
33
33
  - Besides the standard reply headings, add a \`## Verdict\` section containing \`pass\` or \`fail\`, and put your prioritised list of issues under Open items.
34
34
  `,
35
35
  };
36
- const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
36
+ export const GLOBAL_TEAM_YAML = `# Global agent library. Projects pick agents from here by name and may override any field.
37
37
  # Relative paths here are resolved against this folder. "~" is allowed.
38
38
  agents:
39
39
  lead:
40
40
  runtime: claude-code # claude-code | codex
41
41
  agent_md: agents/lead/AGENT.md
42
42
  memory: { global: agents/lead/memory }
43
+ # model: opus # most capable model: keep it for the lead
43
44
  fe-member:
44
45
  runtime: claude-code
45
46
  agent_md: agents/fe-member/AGENT.md
46
47
  memory: { global: agents/fe-member/memory }
48
+ # model: sonnet # cheaper model for routine work
47
49
  qa-member:
48
50
  runtime: claude-code
49
51
  agent_md: agents/qa-member/AGENT.md
50
52
  memory: { global: agents/qa-member/memory }
53
+ # model: sonnet # cheaper model for routine work
54
+ # Model hints (comments only):
55
+ # - a read-only search/explorer role suits "model: haiku", the cheapest tier (the alias resolves per provider; on the Anthropic API it is claude-haiku-5-5).
56
+ # - a reviewer can run on the other runtime ("runtime: codex"); leave the codex model unset to use the CLI default.
51
57
  `;
52
58
  export function initHome(home) {
53
59
  const hp = homePaths(home);
package/dist/schema.d.ts CHANGED
@@ -12,7 +12,7 @@ export declare function inferRuntime(model: string | undefined): Runtime | undef
12
12
  export declare const WEB_LEVELS: readonly ["none", "search", "fetch"];
13
13
  export type WebLevel = (typeof WEB_LEVELS)[number];
14
14
  /** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
15
- export declare const ERROR_KINDS: readonly ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
15
+ export declare const ERROR_KINDS: readonly ["rate_limit", "quota", "timeout", "network", "crash", "auth", "config", "unknown"];
16
16
  export type ErrorKind = (typeof ERROR_KINDS)[number];
17
17
  /** One link of a fallback chain after resolution: runtime and model are filled in (model "" = the runtime's own default). */
18
18
  export interface FallbackEntry {
@@ -37,6 +37,14 @@ export declare const FallbackEntryPartial: z.ZodObject<{
37
37
  }>>;
38
38
  }, z.core.$strict>;
39
39
  export type FallbackEntryPartialT = z.infer<typeof FallbackEntryPartial>;
40
+ /** Per-agent sandbox allowances (spec 25); every key is optional and absent = none. */
41
+ export declare const SandboxPartial: z.ZodObject<{
42
+ write: z.ZodOptional<z.ZodArray<z.ZodString>>;
43
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
44
+ local_binding: z.ZodOptional<z.ZodBoolean>;
45
+ unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
46
+ }, z.core.$strict>;
47
+ export type SandboxPartialT = z.infer<typeof SandboxPartial>;
40
48
  export declare const AgentPartial: z.ZodObject<{
41
49
  runtime: z.ZodOptional<z.ZodEnum<{
42
50
  "claude-code": "claude-code";
@@ -80,6 +88,12 @@ export declare const AgentPartial: z.ZodObject<{
80
88
  xhigh: "xhigh";
81
89
  }>>;
82
90
  }, z.core.$strict>>>;
91
+ sandbox: z.ZodOptional<z.ZodObject<{
92
+ write: z.ZodOptional<z.ZodArray<z.ZodString>>;
93
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
94
+ local_binding: z.ZodOptional<z.ZodBoolean>;
95
+ unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
96
+ }, z.core.$strict>>;
83
97
  owns: z.ZodOptional<z.ZodUnknown>;
84
98
  }, z.core.$strict>;
85
99
  export type AgentPartialT = z.infer<typeof AgentPartial>;
@@ -113,7 +127,18 @@ export declare const DispatcherPartial: z.ZodObject<{
113
127
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
114
128
  export declare const REMOVED_DISPATCHER_KEYS: readonly ["max_parallel", "max_concurrent_runs", "workspace_mode"];
115
129
  export declare const REMOVED_AGENT_KEYS: readonly ["owns"];
130
+ /** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
131
+ export declare const ModelTiersPartial: z.ZodObject<{
132
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
133
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
134
+ }, z.core.$strict>;
135
+ export declare const TIERS: readonly ["low", "mid", "high"];
136
+ export type Tier = (typeof TIERS)[number];
116
137
  export declare const GlobalConfig: z.ZodObject<{
138
+ model_tiers: z.ZodOptional<z.ZodObject<{
139
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
140
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
141
+ }, z.core.$strict>>;
117
142
  agents: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodObject<{
118
143
  runtime: z.ZodOptional<z.ZodEnum<{
119
144
  "claude-code": "claude-code";
@@ -157,6 +182,12 @@ export declare const GlobalConfig: z.ZodObject<{
157
182
  xhigh: "xhigh";
158
183
  }>>;
159
184
  }, z.core.$strict>>>;
185
+ sandbox: z.ZodOptional<z.ZodObject<{
186
+ write: z.ZodOptional<z.ZodArray<z.ZodString>>;
187
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
188
+ local_binding: z.ZodOptional<z.ZodBoolean>;
189
+ unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
190
+ }, z.core.$strict>>;
160
191
  owns: z.ZodOptional<z.ZodUnknown>;
161
192
  }, z.core.$strict>>>;
162
193
  }, z.core.$strict>;
@@ -165,6 +196,10 @@ export declare const ProjectConfig: z.ZodObject<{
165
196
  team: z.ZodObject<{
166
197
  lead: z.ZodString;
167
198
  }, z.core.$strict>;
199
+ model_tiers: z.ZodOptional<z.ZodObject<{
200
+ "claude-code": z.ZodOptional<z.ZodArray<z.ZodString>>;
201
+ codex: z.ZodOptional<z.ZodArray<z.ZodString>>;
202
+ }, z.core.$strict>>;
168
203
  dispatcher: z.ZodDefault<z.ZodObject<{
169
204
  max_rounds: z.ZodOptional<z.ZodNumber>;
170
205
  max_parallel: z.ZodOptional<z.ZodUnknown>;
@@ -235,6 +270,12 @@ export declare const ProjectConfig: z.ZodObject<{
235
270
  xhigh: "xhigh";
236
271
  }>>;
237
272
  }, z.core.$strict>>>;
273
+ sandbox: z.ZodOptional<z.ZodObject<{
274
+ write: z.ZodOptional<z.ZodArray<z.ZodString>>;
275
+ network: z.ZodOptional<z.ZodArray<z.ZodString>>;
276
+ local_binding: z.ZodOptional<z.ZodBoolean>;
277
+ unix_sockets: z.ZodOptional<z.ZodArray<z.ZodString>>;
278
+ }, z.core.$strict>>;
238
279
  owns: z.ZodOptional<z.ZodUnknown>;
239
280
  }, z.core.$strict>>>;
240
281
  }, z.core.$strict>;
package/dist/schema.js CHANGED
@@ -21,7 +21,7 @@ export function inferRuntime(model) {
21
21
  /** How much of the web an agent may use: nothing, search only, or search plus fetching pages. Unset leaves each runtime's own default alone. */
22
22
  export const WEB_LEVELS = ["none", "search", "fetch"];
23
23
  /** Why a wake failed, as far as the CLI's output reveals; decides whether to retry, fall back, or stop. */
24
- export const ERROR_KINDS = ["rate_limit", "quota", "timeout", "network", "crash", "auth", "unknown"];
24
+ export const ERROR_KINDS = ["rate_limit", "quota", "timeout", "network", "crash", "auth", "config", "unknown"];
25
25
  const Memory = z
26
26
  .object({
27
27
  global: z.string().optional(),
@@ -36,6 +36,18 @@ export const FallbackEntryPartial = z
36
36
  effort: z.enum(EFFORTS).optional(),
37
37
  })
38
38
  .strict();
39
+ const DOMAIN_RE = /^(\*\.)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
40
+ /** Per-agent sandbox allowances (spec 25); every key is optional and absent = none. */
41
+ export const SandboxPartial = z
42
+ .object({
43
+ write: z.array(z.string().trim().min(1)).optional(),
44
+ network: z
45
+ .array(z.string().trim().toLowerCase().regex(DOMAIN_RE, "expected a host name like registry.npmjs.org or *.example.com (no scheme, port, path or IP)"))
46
+ .optional(),
47
+ local_binding: z.boolean().optional(),
48
+ unix_sockets: z.array(z.string().trim().min(1)).optional(),
49
+ })
50
+ .strict();
39
51
  export const AgentPartial = z
40
52
  .object({
41
53
  runtime: z.enum(RUNTIMES).optional(),
@@ -49,6 +61,7 @@ export const AgentPartial = z
49
61
  can_ask_user: z.boolean().optional(),
50
62
  allow_web: z.enum(WEB_LEVELS).optional(),
51
63
  fallback: z.array(FallbackEntryPartial).optional(),
64
+ sandbox: SandboxPartial.optional(),
52
65
  /** Removed setting: still accepted (any value) so old files load; ignored, with a warning from `validate`. */
53
66
  owns: z.unknown().optional(),
54
67
  })
@@ -72,8 +85,17 @@ export const DispatcherPartial = z
72
85
  /** Settings that no longer exist; files that still carry them load, and `validate` warns. */
73
86
  export const REMOVED_DISPATCHER_KEYS = ["max_parallel", "max_concurrent_runs", "workspace_mode"];
74
87
  export const REMOVED_AGENT_KEYS = ["owns"];
88
+ /** One runtime's model ladder, lowest first; `[]` = no ladder for that runtime (overrides team.yaml). */
89
+ export const ModelTiersPartial = z
90
+ .object({
91
+ "claude-code": z.array(z.string().trim().min(1)).optional(),
92
+ codex: z.array(z.string().trim().min(1)).optional(),
93
+ })
94
+ .strict();
95
+ export const TIERS = ["low", "mid", "high"];
75
96
  export const GlobalConfig = z
76
97
  .object({
98
+ model_tiers: ModelTiersPartial.optional(),
77
99
  agents: z.record(z.string(), AgentPartial).default({}),
78
100
  })
79
101
  .strict();
@@ -81,6 +103,7 @@ export const ProjectConfig = z
81
103
  .object({
82
104
  dir: z.string(),
83
105
  team: z.object({ lead: z.string() }).strict(),
106
+ model_tiers: ModelTiersPartial.optional(),
84
107
  dispatcher: DispatcherPartial.default({}),
85
108
  agents: z.record(z.string(), AgentPartial).default({}),
86
109
  })