agent-lyceum 0.17.3 → 0.19.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/config.js CHANGED
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { absPath, homePaths, projectPaths, } from "./paths.js";
5
- import { DISPATCHER_DEFAULTS, GlobalConfig, ProjectConfig, inferRuntime, } from "./schema.js";
5
+ import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, } from "./schema.js";
6
6
  export class ConfigError extends Error {
7
7
  }
8
8
  function readYaml(file, schema, label) {
@@ -58,7 +58,8 @@ export function resolveProjectFrom(home, name, g, p) {
58
58
  const paths = projectPaths(home, name);
59
59
  const hp = homePaths(home);
60
60
  const lead = p.team.lead;
61
- const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(p.dispatcher) };
61
+ const { max_parallel: _mp, max_concurrent_runs: _mc, workspace_mode: _wm, fallback: dispatcherFallback, ...liveDispatcher } = p.dispatcher;
62
+ const dispatcher = { ...DISPATCHER_DEFAULTS, ...stripUndef(liveDispatcher) };
62
63
  const agents = {};
63
64
  for (const agentName of Object.keys(p.agents)) {
64
65
  const gl = resolveLayer(g.agents[agentName], home);
@@ -128,19 +129,32 @@ export function resolveProjectFrom(home, name, g, p) {
128
129
  break;
129
130
  }
130
131
  }
132
+ // Precedence: the agent's own chain (project > global), then dispatcher.fallback, then none. `[]` is a real value: no fallback.
133
+ let rawFallback = pick("fallback");
134
+ let fallbackShared = false;
135
+ if (rawFallback === undefined) {
136
+ fallbackShared = dispatcherFallback !== undefined;
137
+ if (dispatcherFallback !== undefined) {
138
+ rawFallback = dispatcherFallback;
139
+ sources.fallback = "project";
140
+ }
141
+ else {
142
+ rawFallback = [];
143
+ sources.fallback = "default";
144
+ }
145
+ }
146
+ const { entries: fallback, invalid: fallbackInvalid } = resolveFallback(rawFallback, runtime);
131
147
  const isLead = agentName === lead;
132
148
  const resume = pick("resume");
133
149
  const canMessage = pick("can_message");
134
150
  const canEdit = pick("can_edit_agent_md");
135
151
  const canAsk = pick("can_ask_user");
136
152
  const allowWeb = pick("allow_web");
137
- const owns = pick("owns");
138
153
  for (const [k, v] of [
139
154
  ["resume", resume],
140
155
  ["can_message", canMessage],
141
156
  ["can_edit_agent_md", canEdit],
142
157
  ["can_ask_user", canAsk],
143
- ["owns", owns],
144
158
  ]) {
145
159
  if (v === undefined)
146
160
  sources[k] = "default";
@@ -157,7 +171,9 @@ export function resolveProjectFrom(home, name, g, p) {
157
171
  canEditAgentMd: canEdit ?? isLead,
158
172
  canAskUser: canAsk ?? isLead,
159
173
  allowWeb,
160
- owns: owns ?? [],
174
+ fallback,
175
+ fallbackShared,
176
+ fallbackInvalid,
161
177
  sources,
162
178
  };
163
179
  }
@@ -169,7 +185,45 @@ export function resolveProjectFrom(home, name, g, p) {
169
185
  dispatcher,
170
186
  agents,
171
187
  paths,
188
+ ignoredSettings: collectIgnored(hp.globalConfig, g, paths.config, p),
189
+ };
190
+ }
191
+ /** Fill each entry's runtime: `inferRuntime(model)`, else the agent's own. Entries with nothing to go on are set aside, not guessed. */
192
+ function resolveFallback(raw, agentRuntime) {
193
+ const entries = [];
194
+ const invalid = [];
195
+ for (const [index, e] of raw.entries()) {
196
+ if (e.runtime === undefined && !e.model) {
197
+ invalid.push({ index, reason: "empty" });
198
+ continue;
199
+ }
200
+ const runtime = e.runtime ?? inferRuntime(e.model) ?? agentRuntime;
201
+ if (!runtime) {
202
+ invalid.push({ index, reason: "no-runtime" });
203
+ continue;
204
+ }
205
+ entries.push({ runtime, model: e.model ?? "", ...(e.effort ? { effort: e.effort } : {}) });
206
+ }
207
+ return { entries, invalid };
208
+ }
209
+ /** The removed keys present in the global and project files, by full path, per file (empty files omitted). */
210
+ function collectIgnored(globalFile, g, projectFile, p) {
211
+ const out = [];
212
+ const scan = (file, dispatcher, agents) => {
213
+ const keys = [];
214
+ for (const k of REMOVED_DISPATCHER_KEYS)
215
+ if (dispatcher?.[k] !== undefined)
216
+ keys.push(`dispatcher.${k}`);
217
+ for (const [name, a] of Object.entries(agents))
218
+ for (const k of REMOVED_AGENT_KEYS)
219
+ if (a[k] !== undefined)
220
+ keys.push(`agents.${name}.${k}`);
221
+ if (keys.length)
222
+ out.push({ file, keys });
172
223
  };
224
+ scan(globalFile, undefined, g.agents);
225
+ scan(projectFile, p.dispatcher, p.agents);
226
+ return out;
173
227
  }
174
228
  function stripUndef(o) {
175
229
  return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
@@ -205,7 +259,7 @@ export function findProjectForCwd(home, cwd) {
205
259
  }
206
260
  return best?.name;
207
261
  }
208
- const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "owns"];
262
+ const AGENT_FIELDS = ["runtime", "model", "effort", "agent_md", "memory.global", "memory.project", "resume", "can_message", "can_edit_agent_md", "can_ask_user", "allow_web", "fallback"];
209
263
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
210
264
  export function resolveProjectWithSources(home, name) {
211
265
  const project = resolveProject(home, name);
@@ -216,7 +270,10 @@ export function resolveProjectWithSources(home, name) {
216
270
  dir: { file: projectFile, key: "dir" },
217
271
  "team.lead": { file: projectFile, key: "team.lead" },
218
272
  };
273
+ const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
219
274
  for (const k of Object.keys(DISPATCHER_DEFAULTS)) {
275
+ if (k === "fallback_cooldown_sec" && !anyFallback)
276
+ continue;
220
277
  sources[`dispatcher.${k}`] = raw.dispatcher[k] !== undefined ? { file: projectFile, key: `dispatcher.${k}` } : { default: true };
221
278
  }
222
279
  for (const a of Object.values(project.agents)) {
@@ -232,6 +289,8 @@ export function resolveProjectWithSources(home, name) {
232
289
  const src = a.sources[f];
233
290
  if (src === undefined)
234
291
  continue; // not set anywhere (e.g. no effort)
292
+ if (f === "fallback" && a.fallback.length === 0)
293
+ continue;
235
294
  if (src === "model")
236
295
  sources[`agents.${a.name}.${f}`] = (() => {
237
296
  const m = at(a.sources.model, "model");
@@ -240,6 +299,9 @@ export function resolveProjectWithSources(home, name) {
240
299
  // A project-level AGENT.md found by convention is not a setting in project.yaml.
241
300
  else if (f === "agent_md" && src === "project" && raw.agents[a.name]?.agent_md === undefined)
242
301
  sources[`agents.${a.name}.${f}`] = { default: true };
302
+ // A chain inherited from dispatcher.fallback lives under that key, not under the agent.
303
+ else if (f === "fallback" && src === "project" && raw.agents[a.name]?.fallback === undefined)
304
+ sources[`agents.${a.name}.${f}`] = { file: projectFile, key: "dispatcher.fallback" };
243
305
  else
244
306
  sources[`agents.${a.name}.${f}`] = at(src, f);
245
307
  }
@@ -249,8 +311,12 @@ export function resolveProjectWithSources(home, name) {
249
311
  /** Every effective setting under the same keys as `resolveProjectWithSources` reports sources for. */
250
312
  export function flattenResolved(project) {
251
313
  const v = { dir: project.dir, "team.lead": project.lead };
252
- for (const [k, val] of Object.entries(project.dispatcher))
314
+ const anyFallback = Object.values(project.agents).some((a) => a.fallback.length > 0);
315
+ for (const [k, val] of Object.entries(project.dispatcher)) {
316
+ if (k === "fallback_cooldown_sec" && !anyFallback)
317
+ continue; // keep `doctor --resolved` unchanged without fallback
253
318
  v[`dispatcher.${k}`] = val;
319
+ }
254
320
  for (const a of Object.values(project.agents)) {
255
321
  const p = `agents.${a.name}`;
256
322
  v[`${p}.runtime`] = a.runtime;
@@ -264,7 +330,8 @@ export function flattenResolved(project) {
264
330
  v[`${p}.can_edit_agent_md`] = a.canEditAgentMd;
265
331
  v[`${p}.can_ask_user`] = a.canAskUser;
266
332
  v[`${p}.allow_web`] = a.allowWeb;
267
- v[`${p}.owns`] = a.owns;
333
+ if (a.fallback.length)
334
+ v[`${p}.fallback`] = a.fallback;
268
335
  }
269
336
  return v;
270
337
  }
package/dist/demo.js CHANGED
@@ -82,7 +82,6 @@ export function prepareDemo(dir, runtime) {
82
82
  // One runtime for the whole team, and the roles scoped to the sample repo.
83
83
  for (const agent of ["lead", "fe-member", "qa-member"])
84
84
  yaml = yaml.replace(` ${agent}:\n`, ` ${agent}:\n runtime: ${runtime}\n`);
85
- yaml = yaml.replace('# owns: ["src/web/**"]', 'owns: ["src/**"]').replace('# owns: ["tests/**"]', 'owns: ["test/**"]');
86
85
  fs.writeFileSync(cfg, yaml);
87
86
  return { dir: root, home, repo };
88
87
  }
@@ -3,4 +3,5 @@ import { newRunId } from "./run-store.js";
3
3
  export { newRunId };
4
4
  export type { ActiveWake, EndReason, RunState, SentTopic, WakeRecord, WakeTopic } from "./run-store.js";
5
5
  export { MAX_DONE_REJECTIONS, RESULT_FILE, type RunOptions, type RunSummary } from "./run-session.js";
6
- export declare function runTeam(opts: RunOptions): Promise<RunSummary>;
6
+ /** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
7
+ export declare function runTeam(opts: Omit<RunOptions, "plan"> & Partial<Pick<RunOptions, "plan">>): Promise<RunSummary>;
@@ -1,7 +1,9 @@
1
1
  import { RunSession } from "./run-session.js";
2
2
  import { newRunId } from "./run-store.js";
3
+ import { planWorkspace } from "./worktree.js";
3
4
  export { newRunId };
4
5
  export { MAX_DONE_REJECTIONS, RESULT_FILE } from "./run-session.js";
6
+ /** `plan` is what the CLI decided before taking the run's lock. The CLI always passes it; the fallback exists only for direct callers and tests. */
5
7
  export async function runTeam(opts) {
6
- return new RunSession(opts).run();
8
+ return new RunSession({ ...opts, plan: opts.plan ?? planWorkspace(opts.project, opts.resume) }).run();
7
9
  }
package/dist/doctor.d.ts CHANGED
@@ -35,7 +35,7 @@ export interface DoctorReport {
35
35
  checks: DoctorCheck[];
36
36
  runtimes: RuntimeCapabilities[];
37
37
  }
38
- export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<RuntimeCapabilities[]>;
38
+ export declare function probeProjectRuntimes(project: ResolvedProject, opts?: ProbeOptions, withFallback?: boolean): Promise<RuntimeCapabilities[]>;
39
39
  /** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
40
40
  export declare function preflightRuntimes(project: ResolvedProject, opts?: ProbeOptions): Promise<{
41
41
  errors: string[];
package/dist/doctor.js CHANGED
@@ -6,7 +6,7 @@ import { tidyAdvice } from "./memory-tidy.js";
6
6
  import { listRunLocks, listUnreadableRunLocks, lockHolderAlive } from "./project-lock.js";
7
7
  import { runInvocation } from "./process-runner.js";
8
8
  import { validateProject } from "./validate.js";
9
- import { dirtyPaths, git, isGitRepo, resolveWorkspaceMode } from "./worktree.js";
9
+ import { dirtyPaths, git, isolationBlocker } from "./worktree.js";
10
10
  const BINARY = { "claude-code": "claude", codex: "codex" };
11
11
  /** Run a read-only CLI query (`--version`, `--help`): bounded in time and output, never an agent task. */
12
12
  async function query(cmd, args, opts) {
@@ -76,7 +76,13 @@ function judgeRuntimes(project, caps) {
76
76
  const cmd = BINARY[c.runtime];
77
77
  const users = Object.values(project.agents).filter((a) => a.runtime === c.runtime);
78
78
  if (c.binary === "missing") {
79
- checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
79
+ if (!users.length) {
80
+ // Only a fallback chain uses it: a backup that is not installed must not stop a run that works until the primary fails.
81
+ const backups = Object.values(project.agents).filter((a) => a.fallback.some((f) => f.runtime === c.runtime));
82
+ checks.push({ level: "warn", subject: c.runtime, message: `${cmd} not found on PATH (it is a fallback for ${backups.map((a) => a.name).join(", ")}).` });
83
+ }
84
+ else
85
+ checks.push({ level: "error", subject: c.runtime, message: `${cmd} not found on PATH (needed by ${users.map((a) => a.name).join(", ")}).` });
80
86
  continue;
81
87
  }
82
88
  checks.push({ level: "ok", subject: c.runtime, message: `${cmd} ${c.version ?? "(version unknown)"}` });
@@ -96,9 +102,12 @@ function judgeRuntimes(project, caps) {
96
102
  }
97
103
  return checks;
98
104
  }
99
- const runtimesUsed = (project) => [...new Set(Object.values(project.agents).map((a) => a.runtime).filter((r) => !!r))];
100
- export async function probeProjectRuntimes(project, opts = {}) {
101
- return Promise.all(runtimesUsed(project).map((r) => probeRuntime(r, opts)));
105
+ /** Runtimes of the agents themselves; with `withFallback`, also those that only a fallback chain uses. */
106
+ const runtimesUsed = (project, withFallback) => [
107
+ ...new Set(Object.values(project.agents).flatMap((a) => [a.runtime, ...(withFallback ? a.fallback.map((f) => f.runtime) : [])]).filter((r) => !!r)),
108
+ ];
109
+ export async function probeProjectRuntimes(project, opts = {}, withFallback = false) {
110
+ return Promise.all(runtimesUsed(project, withFallback).map((r) => probeRuntime(r, opts)));
102
111
  }
103
112
  /** Before a run starts: what is explicitly unsupported (refuse) and what is merely unknown (warn). */
104
113
  export async function preflightRuntimes(project, opts = {}) {
@@ -116,20 +125,19 @@ export async function diagnoseProject(project, opts = {}) {
116
125
  checks.push({ level: i.level === "error" ? "error" : "warn", subject: "config", message: i.message });
117
126
  if (!v.issues.length)
118
127
  checks.push({ level: "ok", subject: "config", message: "Configuration is valid." });
119
- if (fs.existsSync(project.dir) && isGitRepo(project.dir)) {
120
- if (resolveWorkspaceMode(project.dispatcher) === "worktree") {
128
+ const blocker = fs.existsSync(project.dir) ? isolationBlocker(project.dir) : undefined;
129
+ if (fs.existsSync(project.dir)) {
130
+ if (blocker) {
131
+ checks.push({ level: "info", subject: "git", message: `Runs work directly in ${project.dir}, one agent at a time (${blocker}).` });
132
+ }
133
+ else {
121
134
  const dirty = dirtyPaths(project.dir);
122
- if (dirty.length && project.dispatcher.max_concurrent_runs > 1) {
123
- checks.push({ level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: concurrent runs start from HEAD and do not see them.` });
124
- }
125
- else if (dirty.length) {
126
- checks.push({ level: "warn", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: a parallel run would refuse to start until they are committed or stashed.` });
127
- }
128
- else
129
- checks.push({ level: "ok", subject: "git", message: "Repo is clean; parallel runs can start." });
135
+ checks.push(dirty.length
136
+ ? { level: "info", subject: "git", message: `${dirty.length} uncommitted change(s) in ${project.dir}: runs start from HEAD and do not see them.` }
137
+ : { level: "ok", subject: "git", message: "git repo with a commit: runs and agents work in their own worktrees." });
130
138
  }
131
139
  }
132
- if (project.dispatcher.max_concurrent_runs > 1 && fs.existsSync(project.dir) && isGitRepo(project.dir)) {
140
+ if (fs.existsSync(project.dir) && !blocker) {
133
141
  const missing = [];
134
142
  if (!findOnPath("gh", opts.env?.PATH ?? process.env.PATH))
135
143
  missing.push("`gh` is not on PATH");
@@ -161,7 +169,7 @@ export async function diagnoseProject(project, opts = {}) {
161
169
  }
162
170
  for (const a of tidyAdvice(project))
163
171
  checks.push({ level: "info", subject: "memory", message: `${a.agent}: ${a.reason}. Consider: agent-lyceum tidy -p ${project.name} --agent ${a.agent} (doctor only suggests; it never tidies).` });
164
- const runtimes = await probeProjectRuntimes(project, opts);
172
+ const runtimes = await probeProjectRuntimes(project, opts, true);
165
173
  checks.push(...judgeRuntimes(project, runtimes));
166
174
  checks.push({ level: "info", subject: "login", message: "Login status is unknown: agent-lyceum does not read credentials, and the CLIs offer no read-only login check it can rely on." });
167
175
  return { project: project.name, ok: !checks.some((c) => c.level === "error"), checks, runtimes };
@@ -0,0 +1,111 @@
1
+ import type { ResolvedAgent } from "./config.js";
2
+ import type { Effort, ErrorKind, Runtime } from "./schema.js";
3
+ /** One model a wake may run on: the agent's own (`primary`) or a link of its fallback chain. */
4
+ export interface Candidate {
5
+ runtime: Runtime;
6
+ /** Unset = the runtime's own default model. */
7
+ model?: string;
8
+ effort?: Effort;
9
+ primary: boolean;
10
+ /** `<runtime>/<model>`; the cooldown and session key. */
11
+ key: string;
12
+ }
13
+ /**
14
+ * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
15
+ * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
16
+ */
17
+ export declare function candidatesFor(agent: ResolvedAgent): Candidate[];
18
+ /**
19
+ * The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
20
+ * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
21
+ * tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
22
+ */
23
+ export declare function agentForCandidate(agent: ResolvedAgent, c: Candidate): ResolvedAgent;
24
+ /** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
25
+ export declare class Cooldowns {
26
+ private table;
27
+ /** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
28
+ mark(key: string, kind: ErrorKind, now: number, sec: number): void;
29
+ /** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
30
+ active(key: string, now: number): number;
31
+ toJSON(): Record<string, {
32
+ until: string;
33
+ kind: ErrorKind;
34
+ }>;
35
+ /** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
36
+ static from(json?: Record<string, {
37
+ until: string;
38
+ kind: ErrorKind;
39
+ }>): Cooldowns;
40
+ }
41
+ /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
42
+ export declare function planAttempts(agent: ResolvedAgent, cooldowns: Cooldowns, now: number): Candidate[];
43
+ export type Step = "retry-same" | "next-candidate" | "stop";
44
+ /**
45
+ * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
46
+ * candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
47
+ * keeps the old loop: every kind is retried `retry` times, then the wake fails.
48
+ */
49
+ export declare function nextStep(kind: ErrorKind | undefined, attemptsOnCurrent: number, retry: number, hasNext: boolean, legacy: boolean): Step;
50
+ /** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
51
+ export declare function backoffMs(n: number): number;
52
+ /** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
53
+ export declare function defaultSleep(ms: number, signal?: AbortSignal): Promise<void>;
54
+ /** What the driver needs to know about a call's result. */
55
+ export interface AttemptResult {
56
+ ok: boolean;
57
+ kind?: ErrorKind;
58
+ cancelled?: boolean;
59
+ error?: string;
60
+ }
61
+ export interface AttemptCtx<R> {
62
+ /** Calls made so far in this wake, across candidates (0 = the first). */
63
+ attempt: number;
64
+ /** Calls made so far on this candidate; restarts at 0 on a switch. */
65
+ candidateAttempt: number;
66
+ /** The previous call's failed result, for a retry prompt. */
67
+ previous?: R;
68
+ /** Set on the first call after switching: the candidate given up on. */
69
+ switchedFrom?: Candidate;
70
+ }
71
+ /** A failed call, in order, for `failure.tried`. */
72
+ export interface Tried {
73
+ runtime: Runtime;
74
+ model?: string;
75
+ kind?: ErrorKind;
76
+ error?: string;
77
+ }
78
+ export interface RunWithFallbackOptions<R extends AttemptResult> {
79
+ /** From `planAttempts`; tried in order. */
80
+ candidates: Candidate[];
81
+ /** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
82
+ legacy: boolean;
83
+ /** Retries per candidate (`dispatcher.retry`). */
84
+ retry: number;
85
+ /** Marked on `rate_limit`/`quota` failures. */
86
+ cooldowns: Cooldowns;
87
+ cooldownSec: number;
88
+ invoke(candidate: Candidate, ctx: AttemptCtx<R>): Promise<R>;
89
+ /** Runs before each call; "stop" ends the loop without calling (e.g. the round limit). */
90
+ beforeAttempt?(candidate: Candidate, ctx: AttemptCtx<R>): "stop" | undefined;
91
+ /** Runs after each call, awaited before the next decision. */
92
+ afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
93
+ onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
94
+ sleep?(ms: number, signal?: AbortSignal): Promise<void>;
95
+ now?(): number;
96
+ signal?: AbortSignal;
97
+ }
98
+ export interface FallbackOutcome<R> {
99
+ /** The last call's result; undefined only when stopped before the first call. */
100
+ result: R | undefined;
101
+ tried: Tried[];
102
+ /** The candidate of the last call (or the first one, when none was made). */
103
+ used: Candidate;
104
+ /** Why the loop ended without a final verdict from a call: `beforeAttempt` said stop, or the signal aborted during a backoff. */
105
+ stopped?: "beforeAttempt" | "aborted";
106
+ }
107
+ /**
108
+ * The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
109
+ * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
110
+ */
111
+ export declare function runWithFallback<R extends AttemptResult>(o: RunWithFallbackOptions<R>): Promise<FallbackOutcome<R>>;
@@ -0,0 +1,157 @@
1
+ /** `<runtime>/<model>`, the model trimmed and lowercased (no model = ""), so entries naming the same model share one key. */
2
+ const keyOf = (runtime, model) => `${runtime}/${(model ?? "").trim().toLowerCase()}`;
3
+ /**
4
+ * The primary model followed by the agent's fallback chain, in order. An entry with the same key as the primary or as an
5
+ * earlier entry is dropped: a chain shared through `dispatcher.fallback` may list the agent's own primary (spec 20.12).
6
+ */
7
+ export function candidatesFor(agent) {
8
+ // `validate` refuses an agent without a runtime before anything is woken.
9
+ const runtime = agent.runtime;
10
+ const primary = { runtime, model: agent.model, effort: agent.effort, primary: true, key: keyOf(runtime, agent.model) };
11
+ const out = [primary];
12
+ const seen = new Set([primary.key]);
13
+ for (const f of agent.fallback) {
14
+ const model = f.model || undefined;
15
+ const key = keyOf(f.runtime, model);
16
+ if (seen.has(key))
17
+ continue;
18
+ seen.add(key);
19
+ out.push({ runtime: f.runtime, model, effort: f.effort, primary: false, key });
20
+ }
21
+ return out;
22
+ }
23
+ /**
24
+ * The agent as it runs on candidate `c`: itself for the primary; for a backup, the same agent on the backup's runtime, model
25
+ * and effort, at the primary's effective web level (spec 20.12). A set `allow_web` carries over. Unset, claude-code has no web
26
+ * tools while codex keeps its own default, which may search: a codex backup of such an agent gets `none`.
27
+ */
28
+ export function agentForCandidate(agent, c) {
29
+ if (c.primary)
30
+ return agent;
31
+ const allowWeb = agent.allowWeb ?? (agent.runtime === "claude-code" && c.runtime === "codex" ? "none" : undefined);
32
+ return { ...agent, runtime: c.runtime, model: c.model, effort: c.effort, allowWeb };
33
+ }
34
+ /** Models that failed with `rate_limit`/`quota`, and until when (epoch ms) wakes skip them. Shared by a run, kept in `state.json`. */
35
+ export class Cooldowns {
36
+ table = new Map();
37
+ /** Cool `key` down for `sec` seconds from `now`; never shortens a longer cooldown already in place. */
38
+ mark(key, kind, now, sec) {
39
+ if (sec <= 0)
40
+ return;
41
+ const until = now + sec * 1000;
42
+ if ((this.table.get(key)?.until ?? 0) >= until)
43
+ return;
44
+ this.table.set(key, { until, kind });
45
+ }
46
+ /** Milliseconds of cooldown left for `key` at `now`; 0 = none. */
47
+ active(key, now) {
48
+ const until = this.table.get(key)?.until ?? 0;
49
+ return Math.max(0, until - now);
50
+ }
51
+ toJSON() {
52
+ return Object.fromEntries([...this.table].map(([k, v]) => [k, { until: new Date(v.until).toISOString(), kind: v.kind }]));
53
+ }
54
+ /** Rebuild from `toJSON()` output; a missing table (older `state.json`) is an empty one. */
55
+ static from(json) {
56
+ const c = new Cooldowns();
57
+ for (const [k, v] of Object.entries(json ?? {})) {
58
+ const until = Date.parse(v.until);
59
+ if (Number.isFinite(until))
60
+ c.table.set(k, { until, kind: v.kind });
61
+ }
62
+ return c;
63
+ }
64
+ }
65
+ /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
66
+ export function planAttempts(agent, cooldowns, now) {
67
+ const all = candidatesFor(agent);
68
+ const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
69
+ return warm.length ? warm : all;
70
+ }
71
+ /**
72
+ * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
73
+ * candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
74
+ * keeps the old loop: every kind is retried `retry` times, then the wake fails.
75
+ */
76
+ export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
77
+ const retriesLeft = attemptsOnCurrent <= retry;
78
+ if (legacy)
79
+ return retriesLeft ? "retry-same" : "stop";
80
+ switch (kind) {
81
+ case "rate_limit":
82
+ case "quota":
83
+ return hasNext ? "next-candidate" : "stop";
84
+ case "timeout":
85
+ case "network":
86
+ case "crash":
87
+ return retriesLeft ? "retry-same" : hasNext ? "next-candidate" : "stop";
88
+ case "auth":
89
+ return "stop";
90
+ default:
91
+ // `unknown` and unclassified failures: same model only, as before fallback existed.
92
+ return retriesLeft ? "retry-same" : "stop";
93
+ }
94
+ }
95
+ /** Wait before the `n`th retry (1-based) on the same candidate: 5s, 15s, 45s, then 60s. */
96
+ export function backoffMs(n) {
97
+ return Math.min(5 * 3 ** (n - 1), 60) * 1000;
98
+ }
99
+ /** `setTimeout` as a promise that resolves early (never rejects) when `signal` aborts. */
100
+ export function defaultSleep(ms, signal) {
101
+ return new Promise((resolve) => {
102
+ if (signal?.aborted)
103
+ return resolve();
104
+ const done = () => {
105
+ clearTimeout(timer);
106
+ signal?.removeEventListener("abort", done);
107
+ resolve();
108
+ };
109
+ const timer = setTimeout(done, ms);
110
+ signal?.addEventListener("abort", done, { once: true });
111
+ });
112
+ }
113
+ /**
114
+ * The one retry/fallback loop shared by `RunSession.wake` and `call` (spec 20.2 decision 9): call each candidate,
115
+ * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
116
+ */
117
+ export async function runWithFallback(o) {
118
+ const first = o.candidates[0];
119
+ if (!first)
120
+ throw new Error("runWithFallback needs at least one candidate");
121
+ const sleep = o.sleep ?? defaultSleep;
122
+ const now = o.now ?? Date.now;
123
+ const tried = [];
124
+ let index = 0;
125
+ let used = first;
126
+ let ctx = { attempt: 0, candidateAttempt: 0 };
127
+ let result;
128
+ for (;;) {
129
+ if (o.beforeAttempt?.(used, ctx) === "stop")
130
+ return { result, tried, used, stopped: "beforeAttempt" };
131
+ result = await o.invoke(used, ctx);
132
+ await o.afterAttempt?.(used, result, ctx);
133
+ if (result.ok || result.cancelled)
134
+ return { result, tried, used };
135
+ tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
136
+ if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
137
+ o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
138
+ const next = o.candidates[index + 1];
139
+ const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
140
+ if (step === "stop")
141
+ return { result, tried, used };
142
+ if (step === "retry-same") {
143
+ if (!o.legacy) {
144
+ await sleep(backoffMs(ctx.candidateAttempt + 1), o.signal);
145
+ if (o.signal?.aborted)
146
+ return { result, tried, used, stopped: "aborted" };
147
+ }
148
+ ctx = { attempt: ctx.attempt + 1, candidateAttempt: ctx.candidateAttempt + 1, previous: result };
149
+ continue;
150
+ }
151
+ const to = next;
152
+ o.onSwitch?.(used, to, result.kind, result);
153
+ ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
154
+ index++;
155
+ used = to;
156
+ }
157
+ }
package/dist/paths.d.ts CHANGED
@@ -26,5 +26,3 @@ export interface ProjectPaths {
26
26
  export declare function projectPaths(home: string, project: string): ProjectPaths;
27
27
  /** True when `child` is `parent` or inside it. */
28
28
  export declare function isInside(parent: string, child: string): boolean;
29
- /** Directory part of a glob that precedes the first wildcard segment. */
30
- export declare function globStaticPrefix(glob: string): string;
package/dist/paths.js CHANGED
@@ -49,14 +49,3 @@ export function isInside(parent, child) {
49
49
  const rel = path.relative(parent, child);
50
50
  return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
51
51
  }
52
- /** Directory part of a glob that precedes the first wildcard segment. */
53
- export function globStaticPrefix(glob) {
54
- const segs = glob.split("/");
55
- const out = [];
56
- for (const s of segs) {
57
- if (/[*?[\]{}]/.test(s))
58
- break;
59
- out.push(s);
60
- }
61
- return out.join("/");
62
- }
package/dist/policy.d.ts CHANGED
@@ -8,7 +8,6 @@ export interface AgentEnforcement {
8
8
  otherContext: Level;
9
9
  shared: Level;
10
10
  repoInstructions: Level;
11
- owns: Level;
12
11
  /** Absolute paths outside the repo this agent may write. */
13
12
  writablePaths: string[];
14
13
  notes: string[];
@@ -22,9 +21,6 @@ export interface WritePolicy {
22
21
  allowFiles: string[];
23
22
  /** Files/dirs that must stay unwritable (checked even inside the repo). */
24
23
  deny: string[];
25
- /** Repo-relative globs the agent owns (only enforced when max_parallel > 1). */
26
- owns: string[];
27
- restrictRepoToOwns: boolean;
28
24
  }
29
25
  export declare function commonFile(project: ResolvedProject): string;
30
26
  export declare function outboxDir(project: ResolvedProject, agent: string): string;
@@ -41,8 +37,8 @@ export declare function sharedIndex(project: ResolvedProject): string | undefine
41
37
  /** The repo directory this agent works in: its own worktree, or the main repo. */
42
38
  export declare function repoDirFor(project: ResolvedProject, agent: string): string;
43
39
  export declare function writePolicy(project: ResolvedProject, agent: ResolvedAgent): WritePolicy;
40
+ /** Test seam: pretend the OS sandbox is (un)available; `undefined` re-probes the host. */
41
+ export declare function setOsSandboxForTest(available: boolean | undefined): void;
44
42
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
45
- /** Static directory prefixes (absolute) of an agent's `owns` globs. */
46
- export declare function ownsDirs(project: ResolvedProject, agent: ResolvedAgent): string[];
47
43
  /** Can `agent` legitimately change this protected file? */
48
44
  export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;