agent-lyceum 0.27.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/quota.js ADDED
@@ -0,0 +1,141 @@
1
+ import { spawn } from "node:child_process";
2
+ export function parseClaudeUsage(text) {
3
+ const session = text.match(/Current session:\s*(\d+(?:\.\d+)?)%\s*used\s*·\s*resets\s+(.+)/);
4
+ const week = text.match(/Current week(?: \([^)]*\))?:\s*(\d+(?:\.\d+)?)%\s*used\s*·\s*resets\s+(.+)/);
5
+ const windows = [];
6
+ if (session)
7
+ windows.push(windowOf("這 5 小時", Number(session[1]), (session[2] ?? "").trim()));
8
+ if (week)
9
+ windows.push(windowOf("本週", Number(week[1]), (week[2] ?? "").trim()));
10
+ return windows;
11
+ }
12
+ export function codexWindows(limits) {
13
+ return [limits.primary, limits.secondary].flatMap((window) => {
14
+ if (!window || window.usedPercent === undefined)
15
+ return [];
16
+ const mins = window.windowDurationMins ?? 0;
17
+ const resets = window.resetsAt ? new Date(window.resetsAt * 1000).toLocaleString("zh-Hant", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "時間不明";
18
+ return [windowOf(durationLabel(mins), window.usedPercent, resets)];
19
+ });
20
+ }
21
+ function durationLabel(mins) {
22
+ if (mins === 300)
23
+ return "這 5 小時";
24
+ if (mins === 10080)
25
+ return "7 天";
26
+ if (mins > 0 && mins % 1440 === 0)
27
+ return `${mins / 1440} 天`;
28
+ if (mins > 0 && mins % 60 === 0)
29
+ return `${mins / 60} 小時`;
30
+ return mins > 0 ? `${mins} 分鐘` : "用量";
31
+ }
32
+ function windowOf(label, used, resets) {
33
+ const usedPercent = roundPercent(used);
34
+ return { label, used_percent: usedPercent, remaining_percent: roundPercent(Math.max(0, 100 - used)), resets };
35
+ }
36
+ function roundPercent(n) {
37
+ return Math.round(n * 10) / 10;
38
+ }
39
+ export async function claudeQuota() {
40
+ try {
41
+ const text = await capture("claude", ["-p", "--no-session-persistence", "--output-format", "text", "/usage"], 20_000);
42
+ const windows = parseClaudeUsage(text.stdout);
43
+ if (windows.length === 0)
44
+ return { runtime: "claude", windows: [], error: firstLine(text.stderr) || firstLine(text.stdout) || "claude /usage 沒有回傳額度。" };
45
+ return { runtime: "claude", windows };
46
+ }
47
+ catch (error) {
48
+ return { runtime: "claude", windows: [], error: missing("claude", error) };
49
+ }
50
+ }
51
+ export async function codexQuota() {
52
+ try {
53
+ const limits = await readCodexLimits();
54
+ return { runtime: "codex", windows: codexWindows(limits) };
55
+ }
56
+ catch (error) {
57
+ return { runtime: "codex", windows: [], error: missing("codex", error) };
58
+ }
59
+ }
60
+ function missing(cmd, error) {
61
+ const code = error.code;
62
+ if (code === "ENOENT")
63
+ return `找不到 ${cmd}。`;
64
+ return error.message || `${cmd} 沒有回傳額度。`;
65
+ }
66
+ function firstLine(text) {
67
+ return text.split("\n").map((line) => line.trim()).find(Boolean) ?? "";
68
+ }
69
+ function capture(cmd, args, timeoutMs) {
70
+ return new Promise((resolve, reject) => {
71
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
72
+ let stdout = "";
73
+ let stderr = "";
74
+ const timer = setTimeout(() => {
75
+ child.kill();
76
+ reject(new Error(`${cmd} 超過 ${Math.round(timeoutMs / 1000)} 秒沒有回應。`));
77
+ }, timeoutMs);
78
+ child.stdout.on("data", (chunk) => (stdout += String(chunk)));
79
+ child.stderr.on("data", (chunk) => (stderr += String(chunk)));
80
+ child.on("error", (error) => {
81
+ clearTimeout(timer);
82
+ reject(error);
83
+ });
84
+ child.on("close", () => {
85
+ clearTimeout(timer);
86
+ resolve({ stdout, stderr });
87
+ });
88
+ });
89
+ }
90
+ function readCodexLimits() {
91
+ return new Promise((resolve, reject) => {
92
+ const child = spawn("codex", ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"] });
93
+ let buf = "";
94
+ const timer = setTimeout(() => {
95
+ child.kill();
96
+ reject(new Error("codex 超過 20 秒沒有回應。"));
97
+ }, 20_000);
98
+ let done = false;
99
+ const finish = (error, limits) => {
100
+ if (done)
101
+ return;
102
+ done = true;
103
+ clearTimeout(timer);
104
+ child.kill();
105
+ if (error)
106
+ reject(error);
107
+ else
108
+ resolve(limits ?? {});
109
+ };
110
+ child.on("error", (error) => finish(error));
111
+ child.stdout.on("data", (chunk) => {
112
+ buf += String(chunk);
113
+ let cut = buf.indexOf("\n");
114
+ while (cut >= 0) {
115
+ const line = buf.slice(0, cut).trim();
116
+ buf = buf.slice(cut + 1);
117
+ cut = buf.indexOf("\n");
118
+ if (!line)
119
+ continue;
120
+ let message;
121
+ try {
122
+ message = JSON.parse(line);
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ if (message.id === 1 && message.result) {
128
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", method: "initialized", params: {} })}\n`);
129
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 2, method: "account/rateLimits/read", params: {} })}\n`);
130
+ }
131
+ if (message.id === 2) {
132
+ if (message.error)
133
+ finish(new Error(message.error.message || "codex 沒有回傳額度。"));
134
+ else
135
+ finish(undefined, message.result?.rateLimits ?? {});
136
+ }
137
+ }
138
+ });
139
+ child.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { clientInfo: { name: "agent-lyceum", version: "0" }, capabilities: {} } })}\n`);
140
+ });
141
+ }
@@ -1,7 +1,8 @@
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";
7
8
  import { listRunLocks, lockHolderAlive } from "./project-lock.js";
@@ -580,6 +581,8 @@ export class RunSession {
580
581
  const all = candidatesFor(agent, tier && agent.tiers ? modelForTier(agent.tiers, tier) : undefined);
581
582
  const chain = all.length > 1;
582
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)) : [];
583
586
  const ceiling = must(all.find((c) => c.primary), "the agent's own model");
584
587
  const tieredFirst = all[0]?.tiered ? all[0] : undefined;
585
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.
@@ -594,6 +597,7 @@ export class RunSession {
594
597
  let stoppedByCancel = false;
595
598
  const outcome = await runWithFallback({
596
599
  candidates,
600
+ deferred,
597
601
  legacy: !chain,
598
602
  retry: cfg.retry,
599
603
  cooldowns,
@@ -609,6 +613,12 @@ export class RunSession {
609
613
  // The round limit (when one is set) ends the run; this message stays unread for the resume.
610
614
  return state.max_rounds > 0 && state.rounds >= state.max_rounds ? "stop" : undefined;
611
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
+ },
612
622
  onSwitch: (from, to, kind) => {
613
623
  if (!from.tiered)
614
624
  replaced = from;
@@ -651,14 +661,14 @@ export class RunSession {
651
661
  const activeHandling = unread.map((m, i) => ({ ...handling[i], digest: digestOf(m.meta.type, m.body, true) }));
652
662
  state.active[agent.name] = { round, since: new Date(started).toISOString(), handling: activeHandling };
653
663
  this.saveState();
654
- const result = await invoke({
664
+ const result = await invokeWithSandboxChecks(invoke, {
655
665
  ...base,
656
666
  agent: runAs,
657
667
  signal: opts.signal,
658
668
  logDir: attemptLogDir(runDir, att.id),
659
669
  userPrompt,
660
670
  sessionId,
661
- }).catch((e) => ({
671
+ }, agent, { event: (name, fields) => log(name, fields) }, attempt).catch((e) => ({
662
672
  ok: false,
663
673
  text: "",
664
674
  exitCode: null,
@@ -724,6 +734,10 @@ export class RunSession {
724
734
  fault("park:before-finish");
725
735
  finishAttempt(runDir, att.id, "failed", { error: result.error, parked });
726
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
+ }
727
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")}` : ""}. ` +
728
742
  `Anything it already did outside its mailbox (file edits, commands) was not undone: side effects may have happened.`);
729
743
  return result;
@@ -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
+ }
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>;
@@ -168,6 +182,12 @@ export declare const GlobalConfig: z.ZodObject<{
168
182
  xhigh: "xhigh";
169
183
  }>>;
170
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>>;
171
191
  owns: z.ZodOptional<z.ZodUnknown>;
172
192
  }, z.core.$strict>>>;
173
193
  }, z.core.$strict>;
@@ -250,6 +270,12 @@ export declare const ProjectConfig: z.ZodObject<{
250
270
  xhigh: "xhigh";
251
271
  }>>;
252
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>>;
253
279
  owns: z.ZodOptional<z.ZodUnknown>;
254
280
  }, z.core.$strict>>>;
255
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
  })
@@ -13,7 +13,9 @@ export type WatchFrame = {
13
13
  * A task that was running in the previous frame and has completed since is included once more, so the reader sees
14
14
  * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
15
15
  */
16
- export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>): WatchFrame;
16
+ export declare function buildWatchFrame(project: ResolvedProject, tracked: Set<string>, opts?: {
17
+ allTasks?: boolean;
18
+ }): WatchFrame;
17
19
  export interface WatchDeps {
18
20
  /** The project, loaded again each time so config edits and new runs show up. */
19
21
  load: () => ResolvedProject;
@@ -21,6 +23,8 @@ export interface WatchDeps {
21
23
  dir: (project: ResolvedProject) => string;
22
24
  write: (line: string) => void;
23
25
  debounceMs: number;
26
+ /** Include finished tasks. The browser page uses this; `status --json --watch` does not. */
27
+ allTasks?: boolean;
24
28
  watch?: (dir: string, onChange: () => void) => {
25
29
  close: () => void;
26
30
  };
@@ -6,12 +6,14 @@ import { buildStatusReport, buildTaskListReport, isCompletedRun } from "./status
6
6
  * A task that was running in the previous frame and has completed since is included once more, so the reader sees
7
7
  * it finish (and can toast) before it is dropped. `tracked` is that previous set; it is replaced by the ids running now.
8
8
  */
9
- export function buildWatchFrame(project, tracked) {
9
+ export function buildWatchFrame(project, tracked, opts) {
10
10
  const all = buildTaskListReport(project).runs;
11
11
  const running = all.filter((r) => r.state === "running").map((r) => r.run_id);
12
12
  const open = all.filter((r) => !isCompletedRun(r)).map((r) => r.run_id);
13
13
  const runs = {};
14
- for (const id of new Set([...open, ...tracked]))
14
+ // The web page keeps every task. The terminal watch drops one after it has completed, once the finish has been seen.
15
+ const ids = opts?.allTasks ? all.map((r) => r.run_id) : [...open, ...tracked];
16
+ for (const id of new Set(ids))
15
17
  runs[id] = buildStatusReport(project, id);
16
18
  const report = buildStatusReport(project);
17
19
  tracked.clear();
@@ -39,7 +41,7 @@ export function createStatusWatcher(d) {
39
41
  let project;
40
42
  try {
41
43
  project = d.load();
42
- frame = buildWatchFrame(project, tracked);
44
+ frame = buildWatchFrame(project, tracked, { allTasks: d.allTasks });
43
45
  }
44
46
  catch (e) {
45
47
  frame = { schema_version: 1, error: e.message };
package/dist/validate.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import { enforcementFor, memoryDirs } from "./policy.js";
3
+ import { describeConflict, fallbackCapExcess, sandboxConflictsOf } from "./sandbox-invocation.js";
3
4
  import { isInside } from "./paths.js";
4
5
  import { inferRuntime, RUNTIME_EFFORTS, RUNTIMES } from "./schema.js";
5
6
  import { ladderIndex } from "./tiers.js";
@@ -50,6 +51,10 @@ export function validateProject(project) {
50
51
  err(`Agent "${a.name}": effort "${a.effort}" is not supported by ${a.runtime} (use ${RUNTIME_EFFORTS[a.runtime].join(", ")}).`);
51
52
  }
52
53
  validateFallback(project, a, err);
54
+ for (const c of sandboxConflictsOf(project, a, false))
55
+ err(describeConflict(a.name, c, "load"));
56
+ for (const f of fallbackCapExcess(a))
57
+ err(`agent ${a.name}: fallback ${f.label} would apply more sandbox allowance than the primary (${f.excess.join("; ")}).`);
53
58
  if (!fs.existsSync(a.agentMd))
54
59
  err(`Agent "${a.name}": AGENT.md not found at ${a.agentMd}`);
55
60
  if (!a.memory.global && !a.memory.project)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-lyceum",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Config + dispatcher for teams of Claude Code / Codex agents with scoped context and file-based mailboxes",
5
5
  "keywords": [
6
6
  "ai",
@@ -27,7 +27,9 @@
27
27
  },
28
28
  "files": [
29
29
  "dist",
30
- "!dist/**/*.map"
30
+ "!dist/**/*.map",
31
+ "web/dist",
32
+ "!web/dist/**/*.map"
31
33
  ],
32
34
  "engines": {
33
35
  "node": ">=20"
@@ -41,7 +43,7 @@
41
43
  "typecheck": "tsc -p tsconfig.json --noEmit && npm run typecheck:plugin",
42
44
  "test": "vitest run",
43
45
  "prepare": "npm run build",
44
- "prepublishOnly": "npm run typecheck && npm test && npm run build",
46
+ "prepublishOnly": "npm run typecheck && npm test && npm run build && pnpm --dir web install --frozen-lockfile && pnpm --dir web typecheck && pnpm --dir web build",
45
47
  "release": "bash scripts/release.sh",
46
48
  "typecheck:plugin": "tsc -p plugins/status-monitor/tsconfig.ci.json",
47
49
  "check:pack": "node scripts/check-pack.mjs",