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.
- package/README.md +4 -0
- package/README.zh-TW.md +4 -0
- package/dist/adapters/claude.js +11 -0
- package/dist/adapters/codex.js +2 -0
- package/dist/call.js +5 -2
- package/dist/config.d.ts +37 -1
- package/dist/config.js +69 -4
- package/dist/duplicate-notices.d.ts +9 -0
- package/dist/duplicate-notices.js +34 -0
- package/dist/fallback.d.ts +14 -2
- package/dist/fallback.js +34 -6
- package/dist/guard.d.ts +17 -1
- package/dist/guard.js +37 -6
- package/dist/mailbox.d.ts +14 -0
- package/dist/mailbox.js +67 -23
- package/dist/paths.d.ts +2 -0
- package/dist/paths.js +5 -2
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +80 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +30 -3
- package/dist/run-session.d.ts +2 -0
- package/dist/run-session.js +74 -11
- package/dist/run-store.d.ts +4 -1
- package/dist/sandbox-invocation.d.ts +35 -0
- package/dist/sandbox-invocation.js +186 -0
- package/dist/scaffold.d.ts +1 -0
- package/dist/scaffold.js +7 -1
- package/dist/schema.d.ts +42 -1
- package/dist/schema.js +24 -1
- package/dist/status.js +26 -1
- package/dist/tiers.d.ts +27 -0
- package/dist/tiers.js +49 -0
- package/dist/validate.js +33 -1
- package/dist/worktree.js +2 -1
- package/package.json +1 -1
package/dist/run-session.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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);
|
package/dist/run-store.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -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
|
})
|