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 CHANGED
@@ -49,7 +49,7 @@ Its own data (config, mailboxes, logs, memory, worktrees) lives in `~/agent-lyce
49
49
  | `init` | Create the home and the global agent library. [details](docs/commands.md#init) |
50
50
  | `project add` / `project` / `project remove` | Register, list or remove projects. [details](docs/commands.md#project-add) |
51
51
  | `doctor` | Check config, git, lock and runtime CLIs without running agents. [details](docs/commands.md#doctor) |
52
- | `status` | Show agents, mail, the current wake-up and past runs. [details](docs/commands.md#status) |
52
+ | `status` | Show agents, mail, the current wake-up and past runs. `status --web` opens every registered project in a browser. [details](docs/commands.md#status) |
53
53
  | `run` | Give the task to the lead and run until done. [details](docs/commands.md#run) |
54
54
  | `resume` | Continue an interrupted run, or reopen a finished one with a follow-up. [details](docs/commands.md#resume) |
55
55
  | `clear` | Delete a run, or remove a stale lock. [details](docs/commands.md#clear) |
package/README.zh-TW.md CHANGED
@@ -49,7 +49,7 @@ npx agent-lyceum status
49
49
  | `init` | 建立 home 與全域 agent 庫。 [詳細](docs/commands.zh-TW.md#init) |
50
50
  | `project add` / `project` / `project remove` | 註冊、列出或移除專案。 [詳細](docs/commands.zh-TW.md#project-add) |
51
51
  | `doctor` | 不執行 agent,檢查設定、git、鎖與 runtime CLI。 [詳細](docs/commands.zh-TW.md#doctor) |
52
- | `status` | 顯示 agent、信件、目前的喚醒與過去的 run。 [詳細](docs/commands.zh-TW.md#status) |
52
+ | `status` | 顯示 agent、信件、目前的喚醒與過去的 run。`status --web` 在瀏覽器看全部已註冊專案。 [詳細](docs/commands.zh-TW.md#status) |
53
53
  | `run` | 把任務交給 lead,執行到完成。 [詳細](docs/commands.zh-TW.md#run) |
54
54
  | `resume` | 繼續中斷的 run,或用追加需求重開已完成的 run。 [詳細](docs/commands.zh-TW.md#resume) |
55
55
  | `clear` | 刪除 run,或移除殘留的鎖。 [詳細](docs/commands.zh-TW.md#clear) |
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { repoDirFor, scratchCwd, writePolicy } from "../policy.js";
4
+ import { canonicalPath } from "../paths.js";
4
5
  /** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
5
6
  const rule = (tool, p) => `${tool}(/${p})`;
6
7
  function denyRules(p) {
@@ -34,6 +35,15 @@ export function buildClaudeSettings(project, agent, override) {
34
35
  for (const f of pol.allowFiles)
35
36
  allow.push(rule("Edit", f));
36
37
  const deny = pol.deny.flatMap(denyRules);
38
+ // Spec 25: network allowances; never with a policy override (a minimal-authority wake).
39
+ const sb = override ? undefined : agent.sandbox;
40
+ const network = {};
41
+ if (sb?.network.length)
42
+ network.allowedDomains = sb.network;
43
+ if (sb?.localBinding)
44
+ network.allowLocalBinding = true;
45
+ if (sb?.unixSockets.length)
46
+ network.allowUnixSockets = sb.unixSockets.map(canonicalPath);
37
47
  return {
38
48
  // The user's own and plugin hooks (e.g. a hook that asks the user a question) would block every tool call of a headless wake-up.
39
49
  disableAllHooks: true,
@@ -45,6 +55,7 @@ export function buildClaudeSettings(project, agent, override) {
45
55
  allowWrite: [override?.cwd ?? ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
46
56
  denyWrite: pol.deny,
47
57
  },
58
+ ...(Object.keys(network).length ? { network } : {}),
48
59
  },
49
60
  };
50
61
  }
@@ -63,6 +63,8 @@ export function buildCodexInvocation(input) {
63
63
  'approval_policy="never"',
64
64
  "-c",
65
65
  `sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
66
+ // Codex has one switch for all network access; spec 25 never uses it under a policy override.
67
+ ...(!input.policy && agent.sandbox?.network.length ? ["-c", "sandbox_workspace_write.network_access=true"] : []),
66
68
  "--json",
67
69
  "-o",
68
70
  lastFile,
package/dist/call.js CHANGED
@@ -1,5 +1,6 @@
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
5
  import { must } from "./assert.js";
5
6
  import { ConfigError, loadGlobal, resolveProjectFrom } from "./config.js";
@@ -157,7 +158,7 @@ export async function callAgent(o) {
157
158
  return;
158
159
  say(` ${agent.name} falling back to ${to.model ? `${to.runtime}/${to.model}` : to.runtime} (${kind ?? "unknown error"})`);
159
160
  },
160
- invoke: (c) => invoke({
161
+ invoke: (c, ctx) => invokeWithSandboxChecks(invoke, {
161
162
  project,
162
163
  agent: agentForCandidate(agent, c),
163
164
  workDir: callDir,
@@ -166,7 +167,7 @@ export async function callAgent(o) {
166
167
  timeoutSec: project.dispatcher.wake_timeout_sec,
167
168
  signal: o.signal,
168
169
  logDir: logDirFor(c),
169
- }).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
170
+ }, agent, { event: (name, fields) => say(JSON.stringify({ event: name, ...fields })) }, ctx.attempt).catch((e) => ({ ok: false, text: "", exitCode: null, timedOut: false, error: e.message })),
170
171
  });
171
172
  const result = must(outcome.result, "the call's result");
172
173
  const tried = chain ? outcome.tried : undefined;
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import readline from "node:readline/promises";
5
5
  import { spawnSync } from "node:child_process";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
7
  import { Command } from "commander";
7
8
  import { ConfigError, findProjectForCwd, flattenResolved, listProjects, loadGlobal, resolveProject, resolveProjectWithSources } from "./config.js";
8
9
  import { openInEditor, prepareAnswers } from "./ask-answer.js";
@@ -27,6 +28,7 @@ import { createStatusWatcher } from "./status-watch.js";
27
28
  import { LAUNCH_PPID, startedByWrapper, watchParent } from "./parent-watch.js";
28
29
  import { onlyAgent, soloProject } from "./solo.js";
29
30
  import { DEMO_PROJECT, DEMO_TASK, defaultDemoDir, detectRuntime, prepareDemo } from "./demo.js";
31
+ import { claudeQuota, codexQuota } from "./quota.js";
30
32
  const program = new Command();
31
33
  program
32
34
  .name("agent-lyceum")
@@ -764,9 +766,57 @@ program
764
766
  .option("--interval <sec>", "refresh interval for --monitor", "2")
765
767
  .option("--json", "print the report as JSON (no colour); works with --task-list and --task-id")
766
768
  .option("--watch", "with --json: keep running and print one JSON line (the project report plus one per running task) whenever it changes (Ctrl-C to quit)")
767
- .option("--heartbeat <sec>", "with --watch: how often to re-check when no file event came", "5")
769
+ .option("--heartbeat <sec>", "with --watch or --web: how often to re-check when no file event came", "5")
770
+ .option("--web", "open a local page for every registered project")
771
+ .option("--port <n>", "with --web: TCP port", "8787")
772
+ .option("--host <addr>", "with --web: bind address", "127.0.0.1")
773
+ .option("--no-open", "with --web: do not open a browser")
768
774
  .action(async (opts) => {
769
775
  try {
776
+ if (opts.web) {
777
+ if (opts.json || opts.watch || opts.monitor || opts.taskList || opts.taskId !== undefined)
778
+ fail("--web cannot be combined with --json, --watch, --monitor, --task-list or --task-id.");
779
+ if (opts.project)
780
+ fail("--web shows every registered project; it does not take --project.");
781
+ const port = Number(opts.port);
782
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
783
+ fail("--port must be a TCP port.");
784
+ const beat = Number(opts.heartbeat);
785
+ if (!(beat > 0))
786
+ fail("--heartbeat must be a positive number of seconds.");
787
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
788
+ const entry = path.join(root, "web", "dist", "start.js");
789
+ const staticDir = path.join(root, "web", "dist", "public");
790
+ if (!fs.existsSync(entry) || !fs.existsSync(path.join(staticDir, "index.html"))) {
791
+ fail(`The web monitor is not built.\nFrom the repo: pnpm --dir web install && pnpm --dir web build\nExpected ${entry}`);
792
+ }
793
+ const mod = (await import(pathToFileURL(entry).href));
794
+ const page = await mod.startWebServer({
795
+ port,
796
+ host: opts.host,
797
+ open: opts.open !== false,
798
+ staticDir,
799
+ heartbeatMs: beat * 1000,
800
+ factory: {
801
+ list: () => listProjects(home()).map((item) => item.name),
802
+ watch: (name, onFrame) => createStatusWatcher({
803
+ load: () => resolveProject(home(), name),
804
+ dir: (project) => project.paths.runs,
805
+ write: (line) => onFrame(JSON.parse(line)),
806
+ debounceMs: 100,
807
+ allTasks: true,
808
+ }),
809
+ quota: () => Promise.all([claudeQuota(), codexQuota()]),
810
+ },
811
+ });
812
+ console.log(page.url);
813
+ const stop = (code) => {
814
+ void page.close().finally(() => process.exit(code));
815
+ };
816
+ process.on("SIGINT", () => stop(130));
817
+ process.on("SIGTERM", () => stop(143));
818
+ await new Promise(() => { });
819
+ }
770
820
  if (opts.watch) {
771
821
  if (!opts.json)
772
822
  fail("--watch needs --json.");
package/dist/config.d.ts CHANGED
@@ -1,9 +1,15 @@
1
1
  import { type ProjectPaths } from "./paths.js";
2
2
  import type { AgentWorkspace } from "./worktree.js";
3
- import { type DispatcherSettings, type Effort, type FallbackEntry, type Runtime, type WebLevel } from "./schema.js";
3
+ import { type DispatcherSettings, type Effort, type FallbackEntry, type SandboxPartialT, type Runtime, type WebLevel } from "./schema.js";
4
4
  export declare class ConfigError extends Error {
5
5
  }
6
6
  export type Source = "global" | "project" | "default" | "model";
7
+ export interface SandboxAllowances {
8
+ write: string[];
9
+ network: string[];
10
+ localBinding: boolean;
11
+ unixSockets: string[];
12
+ }
7
13
  export interface ResolvedAgent {
8
14
  name: string;
9
15
  runtime?: Runtime;
@@ -22,6 +28,8 @@ export interface ResolvedAgent {
22
28
  canAskUser: boolean;
23
29
  /** Web access for this agent; undefined = not configured, so the runtime keeps its own default. */
24
30
  allowWeb?: WebLevel;
31
+ /** Spec 25 allowances; unset when none is requested (the only switch that keeps output identical). Paths are absolute, `~` expanded, not yet canonical. */
32
+ sandbox?: SandboxAllowances;
25
33
  /** Ordered failover chain (runtime and model filled in); empty = no fallback. */
26
34
  fallback: FallbackEntry[];
27
35
  /** True when the chain came from `dispatcher.fallback` rather than the agent's own setting. */
@@ -102,6 +110,12 @@ export declare function loadGlobal(home: string): {
102
110
  model?: string | undefined;
103
111
  effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
104
112
  }[] | undefined;
113
+ sandbox?: {
114
+ write?: string[] | undefined;
115
+ network?: string[] | undefined;
116
+ local_binding?: boolean | undefined;
117
+ unix_sockets?: string[] | undefined;
118
+ } | undefined;
105
119
  owns?: unknown;
106
120
  }>;
107
121
  };
@@ -150,6 +164,12 @@ export declare function loadProjectRaw(home: string, name: string): {
150
164
  model?: string | undefined;
151
165
  effort?: "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | undefined;
152
166
  }[] | undefined;
167
+ sandbox?: {
168
+ write?: string[] | undefined;
169
+ network?: string[] | undefined;
170
+ local_binding?: boolean | undefined;
171
+ unix_sockets?: string[] | undefined;
172
+ } | undefined;
153
173
  owns?: unknown;
154
174
  }>;
155
175
  };
@@ -177,6 +197,8 @@ export type SourceInfo = {
177
197
  } | {
178
198
  default: true;
179
199
  };
200
+ /** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
201
+ export declare function resolveSandbox(agent: string, s: SandboxPartialT | undefined): SandboxAllowances | undefined;
180
202
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
181
203
  export declare function resolveProjectWithSources(home: string, name: string): {
182
204
  project: ResolvedProject;
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
- import { absPath, escapesParent, homePaths, projectPaths, } from "./paths.js";
4
+ import { absPath, canonicalPath, escapesParent, expandHome, homePaths, projectPaths, } from "./paths.js";
5
5
  import { availableTiers } from "./tiers.js";
6
6
  import { DISPATCHER_DEFAULTS, GlobalConfig, REMOVED_AGENT_KEYS, REMOVED_DISPATCHER_KEYS, ProjectConfig, inferRuntime, RUNTIMES, } from "./schema.js";
7
7
  export class ConfigError extends Error {
@@ -162,6 +162,7 @@ export function resolveProjectFrom(home, name, g, p) {
162
162
  const canEdit = pick("can_edit_agent_md");
163
163
  const canAsk = pick("can_ask_user");
164
164
  const allowWeb = pick("allow_web");
165
+ const sandbox = resolveSandbox(agentName, pick("sandbox"));
165
166
  for (const [k, v] of [
166
167
  ["resume", resume],
167
168
  ["can_message", canMessage],
@@ -183,6 +184,7 @@ export function resolveProjectFrom(home, name, g, p) {
183
184
  canEditAgentMd: canEdit ?? isLead,
184
185
  canAskUser: canAsk ?? isLead,
185
186
  allowWeb,
187
+ ...(sandbox ? { sandbox } : {}),
186
188
  fallback,
187
189
  fallbackShared,
188
190
  fallbackInvalid,
@@ -274,7 +276,39 @@ export function findProjectForCwd(home, cwd) {
274
276
  }
275
277
  return best?.name;
276
278
  }
277
- 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"];
279
+ 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", "sandbox"];
280
+ /** Claude pattern metacharacters and control characters: refused in sandbox paths instead of escaped (spec 25.4.1). */
281
+ const PATH_CHARS = /[*?[\]{}()!\\]/;
282
+ const PATH_META = { test: (p) => PATH_CHARS.test(p) || [...p].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) };
283
+ function sandboxPath(agent, key, i, raw) {
284
+ const at = `agents.${agent}.sandbox.${key}.${i}`;
285
+ if (PATH_META.test(raw))
286
+ throw new ConfigError(`${at}: must not contain pattern characters (* ? [ ] { } ( ) !), backslashes or control characters (got ${JSON.stringify(raw)})`);
287
+ if (/^\$|\$\{?[A-Za-z_]/.test(raw))
288
+ throw new ConfigError(`${at}: environment variables are not expanded; write the absolute path (got "${raw}")`);
289
+ const expanded = expandHome(raw);
290
+ if (!path.isAbsolute(expanded))
291
+ throw new ConfigError(`${at}: must be an absolute path or start with ~/ (got "${raw}")`);
292
+ const resolved = path.resolve(expanded);
293
+ const canon = canonicalPath(resolved);
294
+ for (const p of [resolved, canon])
295
+ if (PATH_META.test(p))
296
+ throw new ConfigError(`${at}: resolved path contains pattern characters (${JSON.stringify(p)})`);
297
+ return resolved;
298
+ }
299
+ /** Spec 25.4.2: validated, deduplicated allowances; undefined when nothing is requested. */
300
+ export function resolveSandbox(agent, s) {
301
+ if (!s)
302
+ return undefined;
303
+ const uniq = (xs) => [...new Set(xs)];
304
+ const write = uniq((s.write ?? []).map((p, i) => sandboxPath(agent, "write", i, p)));
305
+ const unixSockets = uniq((s.unix_sockets ?? []).map((p, i) => sandboxPath(agent, "unix_sockets", i, p)));
306
+ const network = uniq(s.network ?? []);
307
+ const localBinding = s.local_binding === true;
308
+ if (!write.length && !unixSockets.length && !network.length && !localBinding)
309
+ return undefined;
310
+ return { write, network, localBinding, unixSockets };
311
+ }
278
312
  /** The resolved project plus, for every setting, the file and key it was taken from (or "default"). */
279
313
  export function resolveProjectWithSources(home, name) {
280
314
  const project = resolveProject(home, name);
@@ -361,6 +395,8 @@ export function flattenResolved(project) {
361
395
  v[`${p}.allow_web`] = a.allowWeb;
362
396
  if (a.fallback.length)
363
397
  v[`${p}.fallback`] = a.fallback;
398
+ if (a.sandbox)
399
+ v[`${p}.sandbox`] = { write: a.sandbox.write, network: a.sandbox.network, local_binding: a.sandbox.localBinding, unix_sockets: a.sandbox.unixSockets };
364
400
  if (a.tiers)
365
401
  v[`${p}.model_tiers`] = a.tiers;
366
402
  }
@@ -42,6 +42,12 @@ export declare class Cooldowns {
42
42
  }
43
43
  /** The candidates for one wake: the chain minus those cooling down, or the whole chain when every one is (something must be tried). */
44
44
  export declare function planAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
45
+ /**
46
+ * The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
47
+ * waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
48
+ * A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
49
+ */
50
+ export declare function deferredAttempts(all: readonly Candidate[], cooldowns: Cooldowns, now: number): Candidate[];
45
51
  export type Step = "retry-same" | "next-candidate" | "stop";
46
52
  /**
47
53
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
@@ -80,6 +86,8 @@ export interface Tried {
80
86
  export interface RunWithFallbackOptions<R extends AttemptResult> {
81
87
  /** From `planAttempts`; tried in order. */
82
88
  candidates: Candidate[];
89
+ /** From `deferredAttempts`: tried after `candidates`, each one only once its cooldown has ended. */
90
+ deferred?: Candidate[];
83
91
  /** The agent has no fallback chain (see `nextStep`): no backoff, no cooldowns. */
84
92
  legacy: boolean;
85
93
  /** Retries per candidate (`dispatcher.retry`). */
@@ -93,6 +101,8 @@ export interface RunWithFallbackOptions<R extends AttemptResult> {
93
101
  /** Runs after each call, awaited before the next decision. */
94
102
  afterAttempt?(candidate: Candidate, result: R, ctx: AttemptCtx<R>): void | Promise<void>;
95
103
  onSwitch?(from: Candidate, to: Candidate, kind: ErrorKind | undefined, result: R): void;
104
+ /** Runs before the loop sleeps `ms` for `candidate`'s cooldown to end. */
105
+ onCooldownWait?(candidate: Candidate, ms: number): void;
96
106
  sleep?(ms: number, signal?: AbortSignal): Promise<void>;
97
107
  now?(): number;
98
108
  signal?: AbortSignal;
package/dist/fallback.js CHANGED
@@ -75,12 +75,25 @@ export function planAttempts(all, cooldowns, now) {
75
75
  const warm = all.filter((c) => cooldowns.active(c.key, now) === 0);
76
76
  return warm.length ? warm : [...all];
77
77
  }
78
+ /**
79
+ * The candidates `planAttempts` left out because they are cooling down, soonest to recover first. They are tried last, after
80
+ * waiting for their cooldown to end, so a wake-up whose warm models all fail is not lost to a cooldown that ends moments later.
81
+ * A tier's cheaper model is not one of them: stepping back down to it after the ceiling failed would be wrong.
82
+ */
83
+ export function deferredAttempts(all, cooldowns, now) {
84
+ return all
85
+ .filter((c) => !c.tiered && cooldowns.active(c.key, now) > 0)
86
+ .sort((a, b) => cooldowns.active(a.key, now) - cooldowns.active(b.key, now));
87
+ }
78
88
  /**
79
89
  * What to do after a failed call (spec 20.2 decisions 2 and 5). `attemptsOnCurrent` counts the calls made on the current
80
90
  * candidate, the failed one included; each candidate gets `retry` retries. `legacy` (the agent has no fallback chain)
81
91
  * keeps the old loop: every kind is retried `retry` times, then the wake fails.
82
92
  */
83
93
  export function nextStep(kind, attemptsOnCurrent, retry, hasNext, legacy) {
94
+ // A refused configuration cannot get better by trying again or on another model (spec 25.19.1).
95
+ if (kind === "config")
96
+ return "stop";
84
97
  const retriesLeft = attemptsOnCurrent <= retry;
85
98
  if (legacy)
86
99
  return retriesLeft ? "retry-same" : "stop";
@@ -122,7 +135,8 @@ export function defaultSleep(ms, signal) {
122
135
  * retry it or move on as `nextStep` says, back off between retries. Success and cancellation end the loop as they are.
123
136
  */
124
137
  export async function runWithFallback(o) {
125
- const first = o.candidates[0];
138
+ const list = [...o.candidates, ...(o.deferred ?? [])];
139
+ const first = list[0];
126
140
  if (!first)
127
141
  throw new Error("runWithFallback needs at least one candidate");
128
142
  const sleep = o.sleep ?? defaultSleep;
@@ -142,7 +156,7 @@ export async function runWithFallback(o) {
142
156
  tried.push({ runtime: used.runtime, model: used.model, kind: result.kind, error: result.error });
143
157
  if (!o.legacy && (result.kind === "rate_limit" || result.kind === "quota"))
144
158
  o.cooldowns.mark(used.key, result.kind, now(), o.cooldownSec);
145
- const next = o.candidates[index + 1];
159
+ const next = list[index + 1];
146
160
  const step = nextStep(result.kind, ctx.candidateAttempt + 1, o.retry, !!next, o.legacy);
147
161
  if (step === "stop")
148
162
  return { result, tried, used };
@@ -157,6 +171,13 @@ export async function runWithFallback(o) {
157
171
  }
158
172
  const to = next;
159
173
  o.onSwitch?.(used, to, result.kind, result);
174
+ const wait = o.legacy ? 0 : o.cooldowns.active(to.key, now());
175
+ if (wait > 0) {
176
+ o.onCooldownWait?.(to, wait);
177
+ await sleep(wait, o.signal);
178
+ if (o.signal?.aborted)
179
+ return { result, tried, used, stopped: "aborted" };
180
+ }
160
181
  ctx = { attempt: ctx.attempt + 1, candidateAttempt: 0, previous: result, switchedFrom: used };
161
182
  index++;
162
183
  used = to;
package/dist/mailbox.js CHANGED
@@ -27,14 +27,31 @@ export function serialize(meta, body) {
27
27
  const fm = YAML.stringify(meta).trimEnd();
28
28
  return `---\n${fm}\n---\n\n${body.trim()}\n`;
29
29
  }
30
+ const ID_KEYS = new Set(["id", "reply_to", "thread", "about", "run_id"]);
30
31
  export function parseRaw(text) {
31
32
  const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text);
32
33
  if (!m)
33
34
  throw new Error("missing frontmatter (--- ... ---)");
34
- const data = YAML.parse(m[1] ?? "");
35
+ const fm = m[1] ?? "";
36
+ const data = YAML.parse(fm);
35
37
  if (!data || typeof data !== "object" || Array.isArray(data))
36
38
  throw new Error("frontmatter is not a mapping");
37
- return { data: data, body: (m[2] ?? "").trim() };
39
+ const out = data;
40
+ // Ids are 8 hex characters: `79e76911` parses as Infinity and `01234567` as 1234567.
41
+ // Only an id key that came out as a number is re-read from its source text; everything else stays as YAML.parse returned it.
42
+ if (Object.entries(out).some(([k, v]) => ID_KEYS.has(k) && typeof v === "number")) {
43
+ const doc = YAML.parseDocument(fm);
44
+ if (YAML.isMap(doc.contents)) {
45
+ for (const pair of doc.contents.items) {
46
+ const key = YAML.isScalar(pair.key) ? String(pair.key.value) : "";
47
+ if (!ID_KEYS.has(key) || typeof out[key] !== "number" || !YAML.isScalar(pair.value))
48
+ continue;
49
+ if (pair.value.type === "PLAIN" && typeof pair.value.source === "string")
50
+ out[key] = pair.value.source;
51
+ }
52
+ }
53
+ }
54
+ return { data: out, body: (m[2] ?? "").trim() };
38
55
  }
39
56
  export function ensureProjectDirs(project) {
40
57
  fs.mkdirSync(path.join(project.paths.shared, "common"), { recursive: true });
package/dist/policy.d.ts CHANGED
@@ -50,3 +50,36 @@ export declare function setOsSandboxForTest(available: boolean | undefined): voi
50
50
  export declare function enforcementFor(project: ResolvedProject, agent: ResolvedAgent): AgentEnforcement;
51
51
  /** Can `agent` legitimately change this protected file? */
52
52
  export declare function mayEditProtected(project: ResolvedProject, agent: ResolvedAgent, file: string): boolean;
53
+ export type SandboxRelation = "equal" | "inside" | "contains";
54
+ export interface SandboxPathConflict {
55
+ key: "write" | "unix_sockets";
56
+ index: number;
57
+ path: string;
58
+ protectedPath: string;
59
+ reason: string;
60
+ relation: SandboxRelation;
61
+ }
62
+ /** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
63
+ export declare function sandboxPathConflicts(entries: {
64
+ key: "write" | "unix_sockets";
65
+ index: number;
66
+ path: string;
67
+ }[], protectedPaths: {
68
+ path: string;
69
+ reason: string;
70
+ }[]): SandboxPathConflict[];
71
+ /** What a runtime really applies of an agent's allowances (spec 25.9): the tuple a fallback must not exceed. */
72
+ export interface SandboxCaps {
73
+ network: "none" | "domains" | "all";
74
+ localBinding: boolean;
75
+ unixSockets: boolean;
76
+ }
77
+ export declare const NETWORK_RANK: {
78
+ readonly none: 0;
79
+ readonly domains: 1;
80
+ readonly all: 2;
81
+ };
82
+ /** Without an OS sandbox nothing is applied, so every runtime is equal there. */
83
+ export declare function sandboxCaps(agent: ResolvedAgent, runtime?: string | undefined): SandboxCaps;
84
+ /** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
85
+ export declare function sandboxCapExcess(primary: SandboxCaps, backup: SandboxCaps): string[];
package/dist/policy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { findOnPath } from "./fs-util.js";
4
+ import { canonicalPath } from "./paths.js";
4
5
  export function commonFile(project) {
5
6
  return path.join(project.paths.shared, "common", "COMMON.md");
6
7
  }
@@ -88,6 +89,8 @@ export function writePolicy(project, agent) {
88
89
  ...(ws ? [ws.gitDir] : []),
89
90
  ...(shared && mayWriteShared ? [shared] : []),
90
91
  ...(isLead ? [recordsDir(project)] : []),
92
+ // Spec 25: canonical, because the OS sandbox compares real paths (`/tmp` is `/private/tmp` on macOS).
93
+ ...(agent.sandbox?.write ?? []).map(canonicalPath),
91
94
  ];
92
95
  const allowFiles = [];
93
96
  if (isLead)
@@ -180,6 +183,8 @@ export function enforcementFor(project, agent) {
180
183
  if (agent.canEditAgentMd)
181
184
  notes.push("can_edit_agent_md widens Codex writable roots to whole agent directories.");
182
185
  }
186
+ if (agent.sandbox)
187
+ notes.push(...sandboxNotes(agent, os));
183
188
  return {
184
189
  agent: agent.name,
185
190
  runtime: agent.runtime ?? "?",
@@ -188,7 +193,82 @@ export function enforcementFor(project, agent) {
188
193
  notes,
189
194
  };
190
195
  }
196
+ /** Doctor lines for an agent's sandbox allowances (spec 25.7, 25.10): what is requested and what its runtime really applies. */
197
+ function sandboxNotes(agent, os) {
198
+ const s = agent.sandbox;
199
+ if (!s)
200
+ return [];
201
+ const claude = agent.runtime === "claude-code";
202
+ const notes = [];
203
+ if (!os) {
204
+ notes.push(claude
205
+ ? "no OS sandbox; sandbox.write changes Edit tool rules only; network/local_binding/unix_sockets have no effect."
206
+ : "no OS sandbox; enforcement is prompt-only; sandbox allowances provide no OS enforcement.");
207
+ }
208
+ if (s.network.length) {
209
+ notes.push(`network: ${s.network.join(", ")} (${claude ? "claude-code: per-domain" : "codex: all hosts"})`);
210
+ if (!claude)
211
+ notes.push(`warn: sandbox.network is all-or-nothing on Codex: this agent may reach any host, not only ${s.network.join(", ")}.`);
212
+ }
213
+ if (s.localBinding) {
214
+ notes.push(claude ? (process.platform === "darwin" ? "local_binding: on" : "local_binding: requested, no effect on this platform (macOS only)") : "local_binding: requested, not supported by codex");
215
+ if (!claude)
216
+ notes.push("warn: sandbox.local_binding has no Codex setting; whether a workspace-write sandbox lets it bind ports is unverified. sandbox.network turns on all network access, not only binding.");
217
+ }
218
+ if (s.unixSockets.length) {
219
+ notes.push(`unix_sockets: ${s.unixSockets.map(canonicalPath).join(", ")} (${claude ? "applied" : "not applied by codex"})`);
220
+ if (!claude)
221
+ notes.push("warn: sandbox.unix_sockets has no per-socket Codex control; no setting emitted.");
222
+ }
223
+ for (const [key, list] of [["write", s.write], ["unix_sockets", s.unixSockets]]) {
224
+ list.forEach((p, i) => {
225
+ if (!fs.existsSync(p))
226
+ notes.push(`info: sandbox.${key}[${i}] ${p} does not exist yet`);
227
+ });
228
+ }
229
+ return notes;
230
+ }
191
231
  /** Can `agent` legitimately change this protected file? */
192
232
  export function mayEditProtected(project, agent, file) {
193
233
  return writePolicy(project, agent).allowFiles.includes(file);
194
234
  }
235
+ const within = (parent, p) => p === parent || p.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep);
236
+ /** Pure (spec 25.4.3): which canonical sandbox paths equal, sit inside or contain a protected path. Callers canonicalize. */
237
+ export function sandboxPathConflicts(entries, protectedPaths) {
238
+ const out = [];
239
+ for (const e of entries) {
240
+ for (const pp of protectedPaths) {
241
+ const relation = e.path === pp.path ? "equal" : within(pp.path, e.path) ? "inside" : within(e.path, pp.path) ? "contains" : undefined;
242
+ if (relation)
243
+ out.push({ ...e, protectedPath: pp.path, reason: pp.reason, relation });
244
+ }
245
+ }
246
+ return out;
247
+ }
248
+ export const NETWORK_RANK = { none: 0, domains: 1, all: 2 };
249
+ /** Without an OS sandbox nothing is applied, so every runtime is equal there. */
250
+ export function sandboxCaps(agent, runtime = agent.runtime) {
251
+ const s = agent.sandbox;
252
+ const none = { network: "none", localBinding: false, unixSockets: false };
253
+ if (!s || !osSandboxAvailable())
254
+ return none;
255
+ if (runtime === "claude-code") {
256
+ return {
257
+ network: s.network.length ? "domains" : "none",
258
+ localBinding: s.localBinding && process.platform === "darwin",
259
+ unixSockets: s.unixSockets.length > 0,
260
+ };
261
+ }
262
+ return { ...none, network: s.network.length ? "all" : "none" };
263
+ }
264
+ /** Components in which `backup` applies more than `primary`, as `name: primary -> backup`. */
265
+ export function sandboxCapExcess(primary, backup) {
266
+ const out = [];
267
+ if (NETWORK_RANK[backup.network] > NETWORK_RANK[primary.network])
268
+ out.push(`network: ${primary.network} -> ${backup.network}`);
269
+ if (backup.localBinding && !primary.localBinding)
270
+ out.push("local_binding: off -> on");
271
+ if (backup.unixSockets && !primary.unixSockets)
272
+ out.push("unix_sockets: off -> on");
273
+ return out;
274
+ }
@@ -44,6 +44,8 @@ export interface PidStartIo {
44
44
  }
45
45
  /** A start time that identifies one process: `proc:<ticks>` on Linux, the `ps` text elsewhere or when /proc cannot be read. */
46
46
  export declare function pidStartFrom(pid: number, io: PidStartIo): string | undefined;
47
+ /** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
48
+ export declare function samePidStart(a: string, b: string): boolean;
47
49
  /**
48
50
  * Is the process that wrote this lock still the same running process? Judged by pid and its start time,
49
51
  * never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
@@ -50,7 +50,8 @@ const realIo = {
50
50
  }
51
51
  },
52
52
  ps: (pid) => {
53
- const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" });
53
+ // C locale: `ps` start times stay in one language. A lock written from a Chinese terminal would otherwise look like a different process.
54
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8", env: { ...process.env, LANG: "C", LC_ALL: "C" } });
54
55
  const out = r.status === 0 ? r.stdout.trim() : "";
55
56
  return out || undefined;
56
57
  },
@@ -60,6 +61,30 @@ function pidStart(pid) {
60
61
  }
61
62
  /** Two start times can only be compared when they were taken the same way. */
62
63
  const sameKind = (a, b) => a.startsWith("proc:") === b.startsWith("proc:");
64
+ const MONTHS = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06", Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" };
65
+ /** `ps -o lstart` in the C locale, or the same instant from a Chinese macOS locale, as `YYYY-MM-DDTHH:mm:ss`. */
66
+ function canonicalLstart(text) {
67
+ const s = text.trim();
68
+ const en = /^[A-Za-z]{3}\s+([A-Za-z]{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(s);
69
+ if (en) {
70
+ const month = MONTHS[en[1] ?? ""];
71
+ if (!month)
72
+ return undefined;
73
+ return `${en[6]}-${month}-${(en[2] ?? "").padStart(2, "0")}T${en[3]}:${en[4]}:${en[5]}`;
74
+ }
75
+ const zh = /^\S+\s+(\d{1,2})月\/\s*(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/.exec(s);
76
+ if (zh)
77
+ return `${zh[6]}-${(zh[1] ?? "").padStart(2, "0")}-${(zh[2] ?? "").padStart(2, "0")}T${zh[3]}:${zh[4]}:${zh[5]}`;
78
+ return undefined;
79
+ }
80
+ /** True when both strings name the same process start. Locale-specific `ps` text for one instant matches. */
81
+ export function samePidStart(a, b) {
82
+ if (a === b)
83
+ return true;
84
+ const left = canonicalLstart(a);
85
+ const right = canonicalLstart(b);
86
+ return !!left && left === right;
87
+ }
63
88
  /**
64
89
  * Is the process that wrote this lock still the same running process? Judged by pid and its start time,
65
90
  * never by hostname (macOS changes it with the network) or by heartbeat age (a busy holder may be late).
@@ -74,7 +99,7 @@ export function lockHolderAlive(info) {
74
99
  }
75
100
  if (info.pid_started) {
76
101
  const now = pidStart(info.pid);
77
- if (now && sameKind(now, info.pid_started) && now !== info.pid_started)
102
+ if (now && sameKind(now, info.pid_started) && !samePidStart(info.pid_started, now))
78
103
  return false;
79
104
  }
80
105
  return true;
@@ -0,0 +1,25 @@
1
+ export type QuotaWindow = {
2
+ label: string;
3
+ used_percent: number;
4
+ remaining_percent: number;
5
+ resets: string;
6
+ };
7
+ export type QuotaReport = {
8
+ runtime: "claude" | "codex";
9
+ windows: QuotaWindow[];
10
+ error?: string;
11
+ };
12
+ export declare function parseClaudeUsage(text: string): QuotaWindow[];
13
+ type LimitWindow = {
14
+ usedPercent?: number;
15
+ windowDurationMins?: number;
16
+ resetsAt?: number;
17
+ };
18
+ type RateLimits = {
19
+ primary?: LimitWindow | null;
20
+ secondary?: LimitWindow | null;
21
+ };
22
+ export declare function codexWindows(limits: RateLimits): QuotaWindow[];
23
+ export declare function claudeQuota(): Promise<QuotaReport>;
24
+ export declare function codexQuota(): Promise<QuotaReport>;
25
+ export {};