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/README.md +1 -1
- package/README.zh-TW.md +1 -1
- package/dist/adapters/claude.js +11 -0
- package/dist/adapters/codex.js +2 -0
- package/dist/call.js +3 -2
- package/dist/cli.js +51 -1
- package/dist/config.d.ts +23 -1
- package/dist/config.js +38 -2
- package/dist/fallback.d.ts +10 -0
- package/dist/fallback.js +23 -2
- package/dist/mailbox.js +19 -2
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +80 -0
- package/dist/project-lock.d.ts +2 -0
- package/dist/project-lock.js +27 -2
- package/dist/quota.d.ts +25 -0
- package/dist/quota.js +141 -0
- package/dist/run-session.js +17 -3
- package/dist/sandbox-invocation.d.ts +35 -0
- package/dist/sandbox-invocation.js +186 -0
- package/dist/schema.d.ts +27 -1
- package/dist/schema.js +14 -1
- package/dist/status-watch.d.ts +5 -1
- package/dist/status-watch.js +5 -3
- package/dist/validate.js +5 -0
- package/package.json +5 -3
- package/web/dist/public/assets/index-B1Ld6Pl5.css +2 -0
- package/web/dist/public/assets/index-D2JeeQpl.js +9 -0
- package/web/dist/public/index.html +13 -0
- package/web/dist/server/http.js +94 -0
- package/web/dist/server/hub.js +97 -0
- package/web/dist/server/open.js +9 -0
- package/web/dist/start.js +58 -0
- package/web/dist/types.js +1 -0
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
|
+
}
|
package/dist/run-session.js
CHANGED
|
@@ -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
|
})
|
package/dist/status-watch.d.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/status-watch.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|