toolroll 0.9.60 → 0.9.61

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.
Files changed (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
@@ -540,7 +540,7 @@ function safeLine(text) {
540
540
  }
541
541
  const toml = (value) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
542
542
  const tomlList = (values) => `[${values.map(toml).join(",")}]`;
543
- /** Claude: only these servers (`--strict-mcp-config`), written to a private 0600 file removed after the run. */
543
+ /** Claude: only these servers, written to a private 0600 file removed after the run (the provider adds `--strict-mcp-config`). */
544
544
  export function claudeToolArgs(launch) {
545
545
  const servers = {};
546
546
  for (const tool of launch.tools) {
@@ -563,49 +563,27 @@ export function claudeToolArgs(launch) {
563
563
  const file = join(dir, "mcp.json");
564
564
  writeFileSync(file, JSON.stringify({ mcpServers: servers }), { mode: 0o600 });
565
565
  return {
566
- argv: ["--strict-mcp-config", "--mcp-config", file],
566
+ argv: ["--mcp-config", file],
567
567
  // A first `npx` download can take longer than the default start-up wait.
568
568
  env: launch.tools.length > 0 ? { MCP_TIMEOUT: "60000" } : {},
569
569
  omitEnv: [],
570
570
  cleanup: () => rmSync(dir, { recursive: true, force: true }),
571
571
  };
572
572
  }
573
- /** The operator's Codex model settings a build keeps when its tools are replaced: how it thinks, never what it can reach. */
574
- const CARRIED_CODEX_SETTINGS = ["model_reasoning_effort", "model_reasoning_summary", "model_verbosity", "service_tier", "personality"];
575
- export function carriedCodexSettings(includeModel, codexHome = process.env["CODEX_HOME"] ?? join(homedir(), ".codex")) {
576
- let text = "";
577
- try {
578
- text = readFileSync(join(codexHome, "config.toml"), "utf8");
579
- }
580
- catch {
581
- return [];
582
- }
583
- const wanted = includeModel ? ["model", ...CARRIED_CODEX_SETTINGS] : CARRIED_CODEX_SETTINGS;
584
- const argv = [];
585
- for (const line of text.split("\n")) {
586
- if (/^\s*\[/.test(line))
587
- break;
588
- const match = /^\s*([a-z_]+)\s*=\s*("[^"\\\n]{0,80}"|[A-Za-z0-9_.-]{1,40})\s*(?:#.*)?$/.exec(line);
589
- if (match !== null && wanted.includes(match[1]))
590
- argv.push("-c", `${match[1]}=${match[2]}`);
591
- }
592
- return argv;
593
- }
594
573
  /** Where the tool launcher lives beside this module, built. */
595
574
  export function toolLauncherPath() {
596
575
  return fileURLToPath(new URL("./tool-launcher.js", import.meta.url));
597
576
  }
598
577
  /**
599
- * Codex: the operator's config.toml is ignored whole (its MCP servers
600
- * with it), ChatGPT apps and plugins are off, the model settings above
601
- * are carried, and each tool is named by `-c` and started through the
602
- * launcher. Codex gives its own environment to the agent's shell whatever
578
+ * Codex: each tool is named by `-c` and started through the launcher. The
579
+ * operator's config.toml, rules, apps, plugins and hooks are already off
580
+ * (the provider's CODEX_BUILD_ISOLATION_ARGV), and nothing of config.toml
581
+ * is carried. Codex gives its own environment to the agent's shell whatever
603
582
  * its policy says, so no secret rides it: each tool's values sit in the
604
583
  * run's private 0600 file and reach only that tool's process.
605
584
  */
606
- export function codexToolArgs(launch, options) {
607
- // ChatGPT apps, connectors and plugins are the operator's account, not the project's: all off (plugins carry their own MCP servers).
608
- const argv = ["--ignore-user-config", ...carriedCodexSettings(options.includeModel, options.codexHome), "-c", "apps._default.enabled=false", "-c", "features.plugins=false"];
585
+ export function codexToolArgs(launch, options = {}) {
586
+ const argv = [];
609
587
  if (launch.tools.length === 0)
610
588
  return { argv, env: {}, omitEnv: [], cleanup: () => undefined };
611
589
  const dir = mkdtempSync(join(tmpdir(), "so-tools-"));
@@ -701,9 +679,8 @@ export function prepareRunTools(store, runId, provider, options) {
701
679
  const launch = limitedTo(toolLaunchFor(store, runId, options.home), options.readOnly, options.withheld);
702
680
  const enabledTools = options.readOnly === undefined ? {} : { enabledTools: options.readOnly };
703
681
  const prepared = provider === "claude" ? claudeToolArgs(launch)
704
- : provider === "codex" ? codexToolArgs(launch, { includeModel: options.includeModel, ...enabledTools })
705
- : provider === "openrouter" ? codexToolArgs(launch, { includeModel: false, ...enabledTools })
706
- : { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
682
+ : provider === "codex" || provider === "openrouter" ? codexToolArgs(launch, enabledTools)
683
+ : { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
707
684
  const skipped = provider === "gemini"
708
685
  ? [...launch.skipped, ...launch.tools.map(one => ({ name: one.spec.name, reason: "Gemini builds can't use project tools yet" }))]
709
686
  : launch.skipped;
@@ -725,13 +702,12 @@ function limitedTo(launch, readOnly, withheld = {}) {
725
702
  };
726
703
  }
727
704
  /** When a launch's tools cannot be prepared, it still gets none of anything else's: the same isolation with an empty list. */
728
- export function noToolsArgs(provider, includeModel) {
729
- const none = { tools: [], skipped: [] };
705
+ export function noToolsArgs(provider) {
730
706
  if (provider === "claude")
731
- return { argv: ["--strict-mcp-config", "--mcp-config", '{"mcpServers":{}}'], env: {}, omitEnv: [], cleanup: () => undefined };
707
+ return { argv: ["--mcp-config", '{"mcpServers":{}}'], env: {}, omitEnv: [], cleanup: () => undefined };
732
708
  if (provider === "gemini")
733
709
  return { argv: [...GEMINI_NO_TOOLS_ARGV], env: {}, omitEnv: [], cleanup: () => undefined };
734
- return codexToolArgs(none, { includeModel: provider === "codex" && includeModel });
710
+ return codexToolArgs({ tools: [], skipped: [] });
735
711
  }
736
712
  /** Add one tool (and, for an import, the values it brings) to a project's builds. */
737
713
  export function addToolTo(store, repo, input, source, by, now, options = {}) {
@@ -64,6 +64,13 @@ export type Invocation = {
64
64
  * Codex renders it as its own sandbox profile; Claude as deny rules for
65
65
  * its file tools (its shell is fenced by the macOS sandbox around it). */
66
66
  fence?: readonly string[];
67
+ /** More paths Claude's own file tools refuse, beside the fence: home
68
+ * configuration its CLI still reads itself (so the sandbox around the
69
+ * whole process can't deny them). */
70
+ fileToolFence?: readonly string[];
71
+ /** The task's own folder (its worktree). A Claude build loads no settings
72
+ * files, so this is where the repository's CLAUDE.md is read from. */
73
+ workspace?: string;
67
74
  /** A running build's steering folder (steer-mailbox.ts): the absolute path
68
75
  * and the worktree-relative name its brief uses. Claude renders a
69
76
  * PostToolUse hook that says when a note is waiting; every other provider
@@ -72,6 +79,11 @@ export type Invocation = {
72
79
  dir: string;
73
80
  name: string;
74
81
  };
82
+ /** Live input (live-input.ts): the brief arrives as the first stdin message
83
+ * and stdin stays open, so the argv carries `--input-format stream-json`
84
+ * and never the brief (Claude drops a `-p` prompt once stdin carries
85
+ * input). Set by the gateway only, for a transport that streams input. */
86
+ streamInput?: boolean;
75
87
  /** Claude's structured-output floor for a non-review phase: the scout's
76
88
  * report rides the terminal result event instead of a file plan mode
77
89
  * would refuse to write. Ignored by providers without `--json-schema`. */
@@ -115,6 +127,9 @@ export type ParsedEnvelope = {
115
127
  tokensIn: number | null;
116
128
  tokensOut: number | null;
117
129
  costUsd: number | null;
130
+ /** The run spent but its cost could not be read (Claude's main result was too large to keep): the gateway charges
131
+ * the run's dollar cap, or records it unmeasured when none applied. Absent = the cost is what costUsd says. */
132
+ costUnknown?: boolean;
118
133
  usageRaw: string | null;
119
134
  /**
120
135
  * Whether the provider was seen to initialize (codex: thread.started;
@@ -152,6 +167,9 @@ export type ParsedEnvelope = {
152
167
  type Adapter = {
153
168
  binary: string;
154
169
  argv(invocation: Invocation): string[];
170
+ /** Environment the launch needs beside the argv (Claude: read the
171
+ * workspace's CLAUDE.md through `--add-dir`). */
172
+ env?(invocation: Invocation): Record<string, string>;
155
173
  /** Prompt input for transports that use stdin instead of a bounded OS argument. */
156
174
  stdin?(invocation: Invocation): string | undefined;
157
175
  parse(stdout: string): ParsedEnvelope;
@@ -167,8 +185,47 @@ type Adapter = {
167
185
  clampTimeout(phase: Phase, requestedMs: number): number;
168
186
  };
169
187
  export declare const OPENROUTER_ENV_KEY = "OPENROUTER_API_KEY";
188
+ /**
189
+ * A build's own setup (plan, build and repair; never a review, which keeps
190
+ * the isolation above). No settings file loads — not the user's, the
191
+ * repository's or the local one — so none of their hooks, plugins or
192
+ * permissions reach the run; Toolroll's one `--settings` (the fence and the
193
+ * steering hook) is all there is. MCP servers come only from `--mcp-config`:
194
+ * the project's named tools, or an explicit empty list. The repository's
195
+ * CLAUDE.md still loads, through `--add-dir` on the workspace. Sign-in is
196
+ * the CLI's own (keychain or ~/.claude), which no setting source governs.
197
+ * `-p` never shows the workspace trust prompt (Claude Code 2.1.290), so a
198
+ * fresh worktree can't wedge a headless run. Checked against 2.1.290 with
199
+ * planted user, project and local hooks and MCP servers: none loaded.
200
+ */
201
+ export declare const CLAUDE_BUILD_ISOLATION_ARGV: readonly string[];
170
202
  /** Claude's own tools that only make sense with a person to wake them. */
171
203
  export declare const HEADLESS_DISALLOWED_TOOLS: readonly string[];
204
+ /**
205
+ * Whether a failed launch was an installed CLI too old for a build's own
206
+ * setup: it names one of those flags as unknown. Then nothing ran, and the
207
+ * run says so plainly instead of reading as a failed sign-in or bad work.
208
+ */
209
+ export declare function isolationUnsupported(provider: ProviderId, stderr: string): string | null;
210
+ /**
211
+ * Codex's own fail-closed equivalent of `CLAUDE_REVIEW_ISOLATION_ARGV`:
212
+ * read-only sandbox rather than the ordinary `workspace-write` (a reviewer
213
+ * dispatched to codex was, until this, handed a write-capable sandbox —
214
+ * the same class of leak the claude side had), approvals that REFUSE
215
+ * rather than wait on a prompt a headless turn can never answer, and the
216
+ * user's own `~/.codex/config.toml` — which can name arbitrary MCP
217
+ * servers, exactly the surface `--strict-mcp-config` closes for claude —
218
+ * ignored outright.
219
+ */
220
+ /**
221
+ * A Codex build's own setup, the equivalent of Claude's: the operator's
222
+ * config.toml (its MCP servers, model choices, profiles) and execpolicy
223
+ * rules ignored, ChatGPT apps and plugins off (plugins carry their own MCP
224
+ * servers), and hooks off. Sign-in still comes from CODEX_HOME, which
225
+ * `--ignore-user-config` leaves alone. The project's named tools follow as
226
+ * `-c mcp_servers.*` (project-tools.ts).
227
+ */
228
+ export declare const CODEX_BUILD_ISOLATION_ARGV: readonly string[];
172
229
  /**
173
230
  * The diagnostic discipline (Phase 3 B6/C6): the harness's own words are
174
231
  * untrusted bytes headed for refusal screens and notifications — controls
@@ -188,11 +245,10 @@ export declare const ALL_CREDENTIAL_ENV: readonly string[];
188
245
  export declare function adapterFor(provider: ProviderId): Adapter;
189
246
  /**
190
247
  * The provider audit: what each harness supports, what we actually pass,
191
- * and what user-global configuration can leak into an unattended run.
192
- * REPORT BEFORE ENFORCEMENT (Codex roadmap review, item 10): none of this
193
- * changes an invocation — it states facts an operator reads on `providers`,
194
- * so hermetic mode can later be turned on per provider from evidence
195
- * instead of hope. `enforced` is a literal false until that day.
248
+ * and what user-global configuration still reaches an unattended run.
249
+ * None of this changes an invocation — it states facts an operator reads
250
+ * on `providers`. Claude and Codex builds now run in their own setup
251
+ * (`enforced`); Gemini's flag is still only reported.
196
252
  */
197
253
  export type ProviderAudit = {
198
254
  /** How output reaches us — and therefore which signals can exist at all. */
@@ -217,13 +273,14 @@ export type ProviderAudit = {
217
273
  */
218
274
  terminalContract: "required" | "none";
219
275
  isolation: {
220
- /** The harness's hermetic flag, if it has one. We do not pass it. */
276
+ /** The harness's hermetic flag, if it has one. */
221
277
  flag: string | null;
222
278
  /** Whether that flag preserves resume. false = documented conflict. */
223
279
  resumeSafe: boolean | null;
224
- enforced: false;
280
+ /** Whether every build, fresh or resumed, passes it. */
281
+ enforced: boolean;
225
282
  };
226
- /** User-global surfaces that can reach an invocation today. */
283
+ /** What still reaches a build: its own sources and the user surfaces left. */
227
284
  configSurface: readonly string[];
228
285
  };
229
286
  /** Read-only facts about a provider — safe anywhere, spawns nothing. */
package/dist/provider.js CHANGED
@@ -21,7 +21,7 @@
21
21
  import { claudeFenceSettings, codexFenceArgv } from "./agent-fence.js";
22
22
  import { claudeSteerHooks } from "./steer-mailbox.js";
23
23
  import {} from "./exec.js";
24
- import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl } from "./exec.js";
24
+ import { runStreamJsonl, runClaudeStreamJsonl, runGeminiStreamJsonl, CLAUDE_COST_UNKNOWN } from "./exec.js";
25
25
  import { scanForSecrets } from "./evidence.js";
26
26
  import { FINDINGS_MODEL_SCHEMA } from "./contracts/review-findings.js";
27
27
  import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
@@ -167,11 +167,26 @@ const CLAUDE_REVIEW_JSON_SCHEMA = {
167
167
  required: ["version"],
168
168
  additionalProperties: false,
169
169
  };
170
+ /**
171
+ * A build's own setup (plan, build and repair; never a review, which keeps
172
+ * the isolation above). No settings file loads — not the user's, the
173
+ * repository's or the local one — so none of their hooks, plugins or
174
+ * permissions reach the run; Toolroll's one `--settings` (the fence and the
175
+ * steering hook) is all there is. MCP servers come only from `--mcp-config`:
176
+ * the project's named tools, or an explicit empty list. The repository's
177
+ * CLAUDE.md still loads, through `--add-dir` on the workspace. Sign-in is
178
+ * the CLI's own (keychain or ~/.claude), which no setting source governs.
179
+ * `-p` never shows the workspace trust prompt (Claude Code 2.1.290), so a
180
+ * fresh worktree can't wedge a headless run. Checked against 2.1.290 with
181
+ * planted user, project and local hooks and MCP servers: none loaded.
182
+ */
183
+ export const CLAUDE_BUILD_ISOLATION_ARGV = ["--setting-sources", "", "--strict-mcp-config"];
184
+ const EMPTY_MCP_CONFIG = '{"mcpServers":{}}';
170
185
  /** Claude's own tools that only make sense with a person to wake them. */
171
186
  export const HEADLESS_DISALLOWED_TOOLS = ["ScheduleWakeup", "CronCreate", "Monitor"];
172
187
  const claudeArgv = (invocation) => [
173
188
  "-p",
174
- invocation.brief,
189
+ ...(invocation.streamInput === true ? ["--input-format", "stream-json"] : [invocation.brief]),
175
190
  ...(invocation.resumeSession === null ? [] : ["--resume", invocation.resumeSession]),
176
191
  // stream-json (arc 1): the terminal result event IS the old buffered
177
192
  // envelope, now arriving as one line of many — the streaming transport
@@ -197,22 +212,47 @@ const claudeArgv = (invocation) => [
197
212
  // turn that a -p process never gets. Denied at the harness too.
198
213
  "--disallowedTools", HEADLESS_DISALLOWED_TOOLS.join(","),
199
214
  ...(invocation.allowedTools === undefined || invocation.allowedTools.length === 0 ? [] : ["--allowedTools", invocation.allowedTools.join(",")]),
215
+ ...CLAUDE_BUILD_ISOLATION_ARGV,
216
+ ...(invocation.workspace === undefined ? [] : ["--add-dir", invocation.workspace]),
200
217
  ...(invocation.toolArgv ?? []),
201
218
  ...(invocation.extraMcpServers === undefined ? [] : ["--mcp-config", JSON.stringify({ mcpServers: invocation.extraMcpServers })]),
219
+ // Strict with nothing named still names its empty list.
220
+ ...((invocation.toolArgv ?? []).includes("--mcp-config") || invocation.extraMcpServers !== undefined ? [] : ["--mcp-config", EMPTY_MCP_CONFIG]),
202
221
  ...claudeSettingsArgv(invocation),
203
222
  ...(invocation.jsonSchema === undefined ? [] : ["--json-schema", JSON.stringify(invocation.jsonSchema)])
204
223
  ]),
205
224
  ];
206
- /** One --settings for a build: the fence's deny rules and the steering doorbell, whichever the run has. */
225
+ /** The build's one --settings: no personal memory, plus the fence and steering doorbell. */
207
226
  function claudeSettingsArgv(invocation) {
208
- const fenced = invocation.fence !== undefined && invocation.fence.length > 0;
209
- if (!fenced && invocation.steerFolder === undefined)
210
- return [];
227
+ const denied = [...(invocation.fence ?? []), ...(invocation.fileToolFence ?? [])];
211
228
  return ["--settings", JSON.stringify({
212
- ...(fenced ? JSON.parse(claudeFenceSettings(invocation.fence)) : {}),
229
+ autoMemoryEnabled: false,
230
+ ...(denied.length > 0 ? JSON.parse(claudeFenceSettings(denied)) : {}),
213
231
  ...(invocation.steerFolder === undefined ? {} : { hooks: claudeSteerHooks(invocation.steerFolder.dir, invocation.steerFolder.name) }),
214
232
  })];
215
233
  }
234
+ /** With no settings sources, only `--add-dir` folders' CLAUDE.md load, and only with this set. */
235
+ const claudeEnv = (invocation) => invocation.phase === "review" || invocation.workspace === undefined ? {} : { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: "1" };
236
+ /** The flags a build's own setup depends on, by provider. */
237
+ const ISOLATION_FLAGS = {
238
+ claude: ["--setting-sources", "--strict-mcp-config", "--settings", "--add-dir", "--mcp-config"],
239
+ codex: ["--ignore-user-config", "--ignore-rules"],
240
+ openrouter: ["--ignore-user-config", "--ignore-rules"],
241
+ };
242
+ /**
243
+ * Whether a failed launch was an installed CLI too old for a build's own
244
+ * setup: it names one of those flags as unknown. Then nothing ran, and the
245
+ * run says so plainly instead of reading as a failed sign-in or bad work.
246
+ */
247
+ export function isolationUnsupported(provider, stderr) {
248
+ const flags = ISOLATION_FLAGS[provider] ?? [];
249
+ const said = stderr.slice(0, 8192);
250
+ const flag = flags.find(one => new RegExp(`(unknown|unexpected|unrecognized) (option|argument)[^\\n]{0,8}${one.replace(/-/g, "\\-")}\\b`, "i").test(said));
251
+ if (flag === undefined)
252
+ return null;
253
+ const name = provider === "claude" ? "Claude Code" : "Codex";
254
+ return `The installed ${name} doesn't support ${flag}, which builds need to run without your own hooks, plugins and MCP servers. Update ${name} and try again.`;
255
+ }
216
256
  /**
217
257
  * The turn's own words, preferring the schema-validated structured field
218
258
  * (present only under `--json-schema`) over the plain result string — the
@@ -267,6 +307,7 @@ function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConf
267
307
  tokensIn: typeof input === "number" && input >= 0 ? input : null,
268
308
  tokensOut: typeof output === "number" && output >= 0 ? output : null,
269
309
  costUsd: typeof cost === "number" && cost >= 0 ? cost : null,
310
+ ...(result !== null && result[CLAUDE_COST_UNKNOWN] === true ? { costUnknown: true } : {}),
270
311
  usageRaw: result?.usage === undefined ? null : JSON.stringify(result.usage).slice(0, USAGE_JSON_CAP),
271
312
  initObserved,
272
313
  // Structural, never prose (finding 15): consumed means the primary
@@ -376,6 +417,21 @@ const codexSandboxArgv = (mode, resuming) => resuming ? ["-c", `sandbox_mode="${
376
417
  * servers, exactly the surface `--strict-mcp-config` closes for claude —
377
418
  * ignored outright.
378
419
  */
420
+ /**
421
+ * A Codex build's own setup, the equivalent of Claude's: the operator's
422
+ * config.toml (its MCP servers, model choices, profiles) and execpolicy
423
+ * rules ignored, ChatGPT apps and plugins off (plugins carry their own MCP
424
+ * servers), and hooks off. Sign-in still comes from CODEX_HOME, which
425
+ * `--ignore-user-config` leaves alone. The project's named tools follow as
426
+ * `-c mcp_servers.*` (project-tools.ts).
427
+ */
428
+ export const CODEX_BUILD_ISOLATION_ARGV = [
429
+ "--ignore-user-config",
430
+ "--ignore-rules",
431
+ "-c", "apps._default.enabled=false",
432
+ "-c", "features.plugins=false",
433
+ "-c", "features.hooks=false",
434
+ ];
379
435
  const CODEX_REVIEW_ISOLATION_ARGV = (resuming) => [
380
436
  "--ignore-user-config",
381
437
  ...codexSandboxArgv("read-only", resuming),
@@ -406,6 +462,7 @@ const codexArgv = (extra) => (invocation) => {
406
462
  ...(invocation.resumeSession === null ? [] : ["resume", invocation.resumeSession]),
407
463
  "--json",
408
464
  "--skip-git-repo-check",
465
+ ...(invocation.phase === "review" ? [] : CODEX_BUILD_ISOLATION_ARGV),
409
466
  ...(invocation.phase === "review"
410
467
  ? CODEX_REVIEW_ISOLATION_ARGV(resuming)
411
468
  // The agent fence: Codex's own sandbox with Toolroll's secrets
@@ -725,6 +782,7 @@ const ADAPTERS = {
725
782
  claude: {
726
783
  binary: "claude",
727
784
  argv: claudeArgv,
785
+ env: claudeEnv,
728
786
  parse: claudeParse,
729
787
  defaultRunner: runClaudeStreamJsonl,
730
788
  extraOmitEnv: foreignCredentialEnv("claude"),
@@ -788,13 +846,14 @@ const AUDITS = {
788
846
  initSignal: "system-init",
789
847
  sessionIdentity: "announced",
790
848
  terminalContract: "none",
791
- // Restricted mode is resume-safe and is enforced for sealed reviewer
792
- // turns. `enforced` remains false here because this provider-wide audit
793
- // also covers ordinary build turns, where the flag is not applied.
794
- isolation: { flag: "--restricted", resumeSafe: true, enforced: false },
849
+ // Builds: no settings sources and strict MCP, fresh and resumed alike
850
+ // (CLAUDE_BUILD_ISOLATION_ARGV). Reviews keep --restricted --safe-mode.
851
+ isolation: { flag: CLAUDE_BUILD_ISOLATION_ARGV.filter(one => one !== "").join(" "), resumeSafe: true, enforced: true },
795
852
  configSurface: [
796
- "~/.claude/CLAUDE.md and settings (hooks, MCP servers, plugins)",
797
- "repository CLAUDE.md / .claude directory",
853
+ "Toolroll's own --settings (the fence and steering hook)",
854
+ "the project's named tools (Settings → Tools)",
855
+ "repository CLAUDE.md",
856
+ "organisation-managed settings",
798
857
  ],
799
858
  },
800
859
  codex: {
@@ -803,10 +862,11 @@ const AUDITS = {
803
862
  initSignal: "thread.started",
804
863
  sessionIdentity: "announced",
805
864
  terminalContract: "none",
806
- // --ephemeral exists and is deliberately not passed: repair resumes
807
- // sessions, and ephemeral runs have none to resume.
808
- isolation: { flag: "--ephemeral", resumeSafe: false, enforced: false },
809
- configSurface: ["~/.codex/config.toml", "repository AGENTS.md"],
865
+ // Builds ignore config.toml and execpolicy rules, with apps, plugins and
866
+ // hooks off (CODEX_BUILD_ISOLATION_ARGV); sign-in stays in CODEX_HOME.
867
+ // Never --ephemeral: repair resumes sessions.
868
+ isolation: { flag: "--ignore-user-config --ignore-rules", resumeSafe: true, enforced: true },
869
+ configSurface: ["the project's named tools (Settings → Tools)", "~/.codex/AGENTS.md and repository AGENTS.md", "sign-in from CODEX_HOME"],
810
870
  },
811
871
  openrouter: {
812
872
  transport: "streaming-jsonl",
@@ -814,10 +874,9 @@ const AUDITS = {
814
874
  initSignal: "thread.started",
815
875
  sessionIdentity: "announced",
816
876
  terminalContract: "none",
817
- isolation: { flag: "--ephemeral", resumeSafe: false, enforced: false },
818
- // The constant -c overrides pin the model provider per invocation, so
819
- // user config cannot reroute the spend — but the file still loads.
820
- configSurface: ["~/.codex/config.toml (model_provider pinned per invocation)", "repository AGENTS.md"],
877
+ // Codex's build setup, with the model provider pinned by constant -c overrides.
878
+ isolation: { flag: "--ignore-user-config --ignore-rules", resumeSafe: true, enforced: true },
879
+ configSurface: ["the project's named tools (Settings → Tools)", "~/.codex/AGENTS.md and repository AGENTS.md", `${OPENROUTER_ENV_KEY}`],
821
880
  },
822
881
  gemini: {
823
882
  transport: "streaming-jsonl",
@@ -6,6 +6,13 @@
6
6
  import type { Store } from "./store.js";
7
7
  /** The ledger action a person's (or lead's) completion of a result writes. */
8
8
  export declare const COMPLETION_ACTION = "assignment handoff checked";
9
+ /** The actor of an automatic completion: the result's work already landed (landed-cleanup.ts). Its ledger detail is
10
+ * the plain-English reason ("its pull request merged"), the one place every surface reads it from. */
11
+ export declare const LANDED_ACTOR = "system:landed";
12
+ /** Who may complete a result, as a completion's ledger actor: a person, a lead, or Toolroll when the work landed. */
13
+ export declare const COMPLETER_ACTOR: RegExp;
14
+ /** Why Toolroll marked a result complete, from its completion's actor and detail; null for a person's completion. */
15
+ export declare function landedReasonOf(actor: string | null | undefined, detail: string | null | undefined): string | null;
9
16
  /** Whether a task's family is marked complete. Completion is recorded on the
10
17
  * family's root against its current version's latest result, so the root, a
11
18
  * superseded revision and the current version all read Complete together. */
@@ -1,6 +1,15 @@
1
1
  import { readVerifiedArtifact } from "./evidence.js";
2
2
  /** The ledger action a person's (or lead's) completion of a result writes. */
3
3
  export const COMPLETION_ACTION = "assignment handoff checked";
4
+ /** The actor of an automatic completion: the result's work already landed (landed-cleanup.ts). Its ledger detail is
5
+ * the plain-English reason ("its pull request merged"), the one place every surface reads it from. */
6
+ export const LANDED_ACTOR = "system:landed";
7
+ /** Who may complete a result, as a completion's ledger actor: a person, a lead, or Toolroll when the work landed. */
8
+ export const COMPLETER_ACTOR = /^(?:(?:operator|coordinator|lead):.+|system:landed)$/;
9
+ /** Why Toolroll marked a result complete, from its completion's actor and detail; null for a person's completion. */
10
+ export function landedReasonOf(actor, detail) {
11
+ return actor === LANDED_ACTOR ? detail?.trim() || "its work is already in main" : null;
12
+ }
4
13
  /** Whether a task's family is marked complete. Completion is recorded on the
5
14
  * family's root against its current version's latest result, so the root, a
6
15
  * superseded revision and the current version all read Complete together. */
package/dist/runner.d.ts CHANGED
@@ -121,8 +121,10 @@ export declare function removeRunnerRepoAuthed(store: Store, args: {
121
121
  export declare const RUNNER_NAME_MAX = 60;
122
122
  /** The most tasks one worker may be set to run at once (the console's register form says the same). */
123
123
  export declare const RUNNER_CAPACITY_MAX = 64;
124
- /** A capacity as typed: a whole number from 1 to RUNNER_CAPACITY_MAX, or null. */
125
- export declare function parseRunnerCapacity(value: string): number | null;
124
+ /** A capacity as typed; changing an existing worker may use zero to drain it. */
125
+ export declare function parseRunnerCapacity(value: string, { allowZero }?: {
126
+ allowZero?: boolean | undefined;
127
+ }): number | null;
126
128
  export declare function validRunnerName(name: string): boolean;
127
129
  /** A generated base name: lowercase [a-z0-9-], collapsed, trimmed. The
128
130
  * suffix budget (finding 24) is the CALLER's arithmetic — truncate the
package/dist/runner.js CHANGED
@@ -165,10 +165,10 @@ export function removeRunnerRepoAuthed(store, args, now) {
165
165
  export const RUNNER_NAME_MAX = 60;
166
166
  /** The most tasks one worker may be set to run at once (the console's register form says the same). */
167
167
  export const RUNNER_CAPACITY_MAX = 64;
168
- /** A capacity as typed: a whole number from 1 to RUNNER_CAPACITY_MAX, or null. */
169
- export function parseRunnerCapacity(value) {
168
+ /** A capacity as typed; changing an existing worker may use zero to drain it. */
169
+ export function parseRunnerCapacity(value, { allowZero = false } = {}) {
170
170
  const capacity = Number(value.trim());
171
- return value.trim() !== "" && Number.isInteger(capacity) && capacity >= 1 && capacity <= RUNNER_CAPACITY_MAX ? capacity : null;
171
+ return value.trim() !== "" && Number.isInteger(capacity) && capacity >= (allowZero ? 0 : 1) && capacity <= RUNNER_CAPACITY_MAX ? capacity : null;
172
172
  }
173
173
  export function validRunnerName(name) {
174
174
  if (name === "" || name.length > RUNNER_NAME_MAX)
package/dist/serve.js CHANGED
@@ -14,7 +14,8 @@ import { startMaintenance } from './maintenance.js';
14
14
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
15
15
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
16
16
  import { projectAuthority } from "./project-access.js";
17
- import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG } from "./project-tools.js";
17
+ import { discoverTools, projectToolsOf, secretsSetFor, TOOL_CATALOG, toolStanding } from "./project-tools.js";
18
+ import { toolRefusal } from "./policy.js";
18
19
  import { isEdgeAddress, refuseEdge } from "./server/edge-refusal.js";
19
20
  import { announcePasswordBearer, isPasswordBearer, PASSWORD_BEARER_REFUSED, passwordBearerAccepted } from "./password-bearer.js";
20
21
  import { createSharedGuards } from "./server/guards.js";
@@ -390,7 +391,24 @@ export function createDecisionServer(options) {
390
391
  repo, name: projectName(repo), holdings: projectHoldings(store, repo), running: projectRunning(store, repo, clock()),
391
392
  canDelete: who.via === "cookie" && store.isInstanceOperator(who.name),
392
393
  builds: projectBuildsOf(repo, who),
394
+ access: projectAccessOf(repo),
393
395
  });
396
+ /** Settings → Project → Build access: the project's named tools and whether each loads for its builds. */
397
+ const projectAccessOf = (repo) => {
398
+ try {
399
+ const policy = store.orgPolicy();
400
+ return { tools: projectToolsOf(store, repo).map(tool => {
401
+ const refused = toolRefusal(policy, tool.name);
402
+ const set = secretsSetFor(repo, tool.spec, toolHome);
403
+ // As a launch decides (toolLaunchFor): a tool the organisation doesn't allow, or missing a secret, is left out.
404
+ return refused !== null ? { name: tool.name, loads: false, words: refused }
405
+ : { name: tool.name, loads: tool.spec.secrets.every(one => one.optional || set.includes(one.name)), words: toolStanding(tool, set).words };
406
+ }) };
407
+ }
408
+ catch {
409
+ return { problem: "Couldn't read this project's tools right now." };
410
+ }
411
+ };
394
412
  /** Settings → Project → Builds at once: the saved number, its workers' capacity, and what builds now. */
395
413
  const projectBuildsOf = (repo, who) => {
396
414
  const capacities = store.listRunners().filter(one => one.retiredAt === null && one.repos.includes(repo)).map(one => one.capacity);
@@ -20,6 +20,9 @@ export type LiveRoom = {
20
20
  } | {
21
21
  kind: "team";
22
22
  conversation: string | null;
23
+ } | {
24
+ kind: "agent";
25
+ run: number;
23
26
  };
24
27
  /** A room's name, read strictly: anything else is not a room. */
25
28
  export declare function parseLiveRoom(name: string): LiveRoom | null;
@@ -1,3 +1,6 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { boardRunOf, holdAgentView, releaseAgentView } from "../agent-board.js";
3
+ import { agentRunFingerprint } from "../agents-board.js";
1
4
  import { createLiveRooms, LiveConnection } from "../live-bus.js";
2
5
  import { reproveRemote } from "../operate-remote.js";
3
6
  import { rowVisible } from "../project.js";
@@ -8,7 +11,7 @@ import { adapterPolicy } from "./route-policy.js";
8
11
  import { requestContext } from "./request-context.js";
9
12
  /** A page asks for a few rooms at once: its workspace, what it shows, its conversation. */
10
13
  export const LIVE_ROOMS_PER_PAGE = 5;
11
- const ROOM_NAME = /^(workspace|task|flow|chat|team)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
14
+ const ROOM_NAME = /^(workspace|task|flow|chat|team|agent)(?::([^?]{1,400}))?(?:\?([^#]{0,600}))?$/;
12
15
  /** At most one workspace nudge a second: a burst of writes is one re-read, the first heard at once. */
13
16
  const WORKSPACE_SETTLE_MS = 1_000;
14
17
  /** A running reply's preview: at most one snapshot every 80 ms; the last one always goes out. */
@@ -45,6 +48,8 @@ export function parseLiveRoom(name) {
45
48
  if (key !== null || !only("conversation"))
46
49
  return null;
47
50
  return { kind, conversation: params.get("conversation") || null };
51
+ case "agent":
52
+ return key !== null && /^[1-9][0-9]{0,9}$/.test(key) && query === undefined ? { kind, run: Number(key) } : null;
48
53
  }
49
54
  return null;
50
55
  }
@@ -61,6 +66,8 @@ export function createLiveHandlers(runtime, live) {
61
66
  return cursor === null ? null : String(cursor);
62
67
  }, null, { bus: live.bus });
63
68
  const chatViewers = new Set();
69
+ // One agent's open view (Agents board): what it shows, keyed by the exact run.
70
+ const agentRooms = createLiveRooms(run => agentRunFingerprint(store, run), null, { bus: live.bus });
64
71
  /** The same session, still signed in, from the cookie and the account (never this request's context). */
65
72
  const readBrowser = (request, who) => {
66
73
  const again = identify(request, false);
@@ -177,7 +184,7 @@ export function createLiveHandlers(runtime, live) {
177
184
  if ([...url.searchParams.keys()].some(key => key !== "room") || names.length === 0 || names.length > LIVE_ROOMS_PER_PAGE || new Set(names).size !== names.length)
178
185
  return json(response, 400, { error: "room" });
179
186
  const rooms = names.map(name => ({ name, room: parseLiveRoom(name) }));
180
- if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
187
+ if (rooms.some(one => one.room === null) || rooms.filter(one => one.room?.kind === "agent").length > 1 || rooms.filter(one => one.room?.kind === "workspace").length > 1 || rooms.filter(one => one.room?.kind === "chat").length > 1 || rooms.filter(one => one.room?.kind === "team").length > 1)
181
188
  return json(response, 400, { error: "room" });
182
189
  // Admit every room before the stream opens, with the answers each page's own stream gave.
183
190
  const joins = [];
@@ -198,6 +205,31 @@ export function createLiveHandlers(runtime, live) {
198
205
  // A sign-out or a narrowed account stops it.
199
206
  valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) }));
200
207
  }
208
+ else if (room.kind === "agent") {
209
+ const browser = who;
210
+ const run = store.getRun(room.run);
211
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
212
+ if (run === null || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
213
+ return json(response, 404, { error: "run" });
214
+ // A repair is its build's own activity: its room is the build's.
215
+ const signedIn = sameBrowser(request, browser), runId = boardRunOf(store, run.id), view = randomBytes(12).toString("hex");
216
+ // The watch is a short lease in the database, renewed on every keep-alive and released when the page goes:
217
+ // the process that sends chat pings reads it there (agent-board.ts watchingAgent).
218
+ const watch = () => { try {
219
+ holdAgentView(store, view, who.name, runId, clock());
220
+ }
221
+ catch { /* a missed renewal only lets a ping through */ } };
222
+ joins.push(connection => {
223
+ watch();
224
+ connection.onKeepAlive(watch);
225
+ connection.onClose(() => { try {
226
+ releaseAgentView(store, view, clock());
227
+ }
228
+ catch { /* it lapses on its own */ } });
229
+ agentRooms.join(runId, { name: who.name, room: name, connection,
230
+ valid: () => signedIn() && rowVisible(liveCeiling(), repo) && store.accountCanAccess(who.name, repo) });
231
+ });
232
+ }
201
233
  else if (room.kind === "flow") {
202
234
  const browser = who;
203
235
  const flow = store.getFlow(room.flow);
@@ -302,6 +334,7 @@ export function createLiveHandlers(runtime, live) {
302
334
  detachChat();
303
335
  workspaceRooms.close();
304
336
  teamRooms.close();
337
+ agentRooms.close();
305
338
  for (const viewer of [...chatViewers])
306
339
  viewer.connection.close();
307
340
  },