toolroll 0.9.65 → 0.9.68

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 (264) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/README.md +20 -2
  3. package/dist/acp-client.js +1 -3
  4. package/dist/agent-board.d.ts +0 -1
  5. package/dist/agent-board.js +0 -1
  6. package/dist/agent-fence.d.ts +69 -2
  7. package/dist/agent-fence.js +294 -11
  8. package/dist/agentconfig.js +4 -1
  9. package/dist/batch-checks.js +3 -2
  10. package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
  11. package/dist/browser/terminal.js +134 -23
  12. package/dist/browser/workspace.css +2 -2
  13. package/dist/browser/workspace.js +31 -29
  14. package/dist/builder.d.ts +4 -0
  15. package/dist/builder.js +56 -37
  16. package/dist/canonical-json.d.ts +14 -0
  17. package/dist/canonical-json.js +36 -0
  18. package/dist/chat-actions.js +1 -2
  19. package/dist/chat-admission.d.ts +19 -0
  20. package/dist/chat-admission.js +22 -0
  21. package/dist/chat-channel.js +1 -1
  22. package/dist/chat-delivery.js +0 -1
  23. package/dist/chat-follow.d.ts +17 -0
  24. package/dist/chat-follow.js +32 -0
  25. package/dist/chat-messages.js +0 -1
  26. package/dist/chat-settings-page.d.ts +23 -0
  27. package/dist/chat-settings-page.js +41 -0
  28. package/dist/claim.js +3 -6
  29. package/dist/claude-print.d.ts +12 -0
  30. package/dist/claude-print.js +34 -0
  31. package/dist/cli-http.d.ts +0 -7
  32. package/dist/cli-http.js +0 -21
  33. package/dist/cli.d.ts +3 -10
  34. package/dist/cli.js +15 -24
  35. package/dist/coding-provider.js +2 -1
  36. package/dist/coding-workspace.js +1 -1
  37. package/dist/{team-cli.d.ts → connect-cli.d.ts} +5 -9
  38. package/dist/connect-cli.js +331 -0
  39. package/dist/containment.d.ts +0 -2
  40. package/dist/containment.js +2 -6
  41. package/dist/contracts/chat-actions.d.ts +0 -1
  42. package/dist/contracts/chat-content.d.ts +0 -4
  43. package/dist/contracts/chat-content.js +0 -2
  44. package/dist/contracts/chat-tables.d.ts +0 -1
  45. package/dist/contracts/cli.d.ts +0 -62
  46. package/dist/contracts/cli.js +0 -4
  47. package/dist/contracts/coding-workspace.js +1 -1
  48. package/dist/contracts/console-api.d.ts +1 -0
  49. package/dist/contracts/console-api.js +1 -0
  50. package/dist/contracts/contract.js +1 -1
  51. package/dist/contracts/flow-card.d.ts +0 -1
  52. package/dist/contracts/flow-propose.d.ts +0 -1
  53. package/dist/contracts/flow-send.js +1 -1
  54. package/dist/contracts/gateway-tools.d.ts +0 -1
  55. package/dist/contracts/integration-metadata.js +1 -1
  56. package/dist/contracts/lead-tools.d.ts +0 -1
  57. package/dist/contracts/memory-pass.d.ts +0 -1
  58. package/dist/contracts/memory-pass.js +1 -1
  59. package/dist/contracts/plan.js +1 -1
  60. package/dist/contracts/project-knowledge.js +1 -1
  61. package/dist/contracts/project-skills.d.ts +0 -1
  62. package/dist/contracts/proof.js +1 -1
  63. package/dist/contracts/recipes.js +1 -1
  64. package/dist/contracts/scope.js +1 -1
  65. package/dist/contracts/scout-report.js +1 -1
  66. package/dist/contracts/teams-callback.d.ts +0 -1
  67. package/dist/converse.d.ts +0 -6
  68. package/dist/converse.js +0 -19
  69. package/dist/daemon.js +3 -2
  70. package/dist/decision.d.ts +1 -3
  71. package/dist/deprecations.d.ts +12 -4
  72. package/dist/deprecations.js +27 -4
  73. package/dist/desktop-update-recovery.js +1 -1
  74. package/dist/desktop-update.js +3 -2
  75. package/dist/discord-chat.js +4 -15
  76. package/dist/discord-settings.d.ts +2 -6
  77. package/dist/discord-settings.js +10 -38
  78. package/dist/discord.js +12 -23
  79. package/dist/evidence.d.ts +0 -1
  80. package/dist/evidence.js +0 -3
  81. package/dist/exec.d.ts +0 -9
  82. package/dist/exec.js +3 -81
  83. package/dist/flow-actions.js +0 -3
  84. package/dist/flow-draft.d.ts +1 -3
  85. package/dist/flow-draft.js +15 -28
  86. package/dist/flow-scripts.d.ts +1 -8
  87. package/dist/flow-scripts.js +0 -13
  88. package/dist/flow-send.js +1 -1
  89. package/dist/flow-share.d.ts +0 -2
  90. package/dist/flow-share.js +1 -6
  91. package/dist/flow-steps.js +1 -1
  92. package/dist/flow-triggers.d.ts +0 -1
  93. package/dist/flow-triggers.js +1 -2
  94. package/dist/flow-unattended.js +1 -12
  95. package/dist/flows.js +1 -1
  96. package/dist/health-cli.d.ts +2 -2
  97. package/dist/health-cli.js +1 -1
  98. package/dist/invoke.d.ts +18 -3
  99. package/dist/invoke.js +142 -17
  100. package/dist/is-record.d.ts +2 -0
  101. package/dist/is-record.js +2 -0
  102. package/dist/lead-doors.d.ts +1 -5
  103. package/dist/lead-doors.js +0 -4
  104. package/dist/lead-tools.js +3 -6
  105. package/dist/live-detail.d.ts +0 -1
  106. package/dist/live-detail.js +0 -6
  107. package/dist/live-terminal.d.ts +24 -13
  108. package/dist/live-terminal.js +81 -43
  109. package/dist/live.d.ts +0 -1
  110. package/dist/live.js +1 -2
  111. package/dist/mcp-core.js +0 -2
  112. package/dist/memory-pass.js +1 -1
  113. package/dist/model-catalog.js +1 -1
  114. package/dist/modes.js +1 -12
  115. package/dist/operate.d.ts +2 -0
  116. package/dist/operate.js +436 -745
  117. package/dist/password-bearer.d.ts +0 -2
  118. package/dist/password-bearer.js +0 -4
  119. package/dist/peek-cli.js +2 -1
  120. package/dist/peek.d.ts +2 -26
  121. package/dist/peek.js +2 -168
  122. package/dist/phase-routing.d.ts +0 -4
  123. package/dist/phase-routing.js +2 -21
  124. package/dist/planner-source.d.ts +2 -1
  125. package/dist/planner-source.js +2 -12
  126. package/dist/planner.js +2 -14
  127. package/dist/process-tree.d.ts +0 -2
  128. package/dist/process-tree.js +0 -10
  129. package/dist/project-delete-ui.d.ts +6 -0
  130. package/dist/project-delete-ui.js +20 -1
  131. package/dist/project-delete.d.ts +0 -2
  132. package/dist/project-delete.js +1 -3
  133. package/dist/project-learning.d.ts +0 -14
  134. package/dist/project-skills.d.ts +0 -1
  135. package/dist/project-skills.js +0 -14
  136. package/dist/project-tools.js +2 -10
  137. package/dist/provider.d.ts +0 -17
  138. package/dist/provider.js +0 -26
  139. package/dist/pulls.d.ts +0 -2
  140. package/dist/pulls.js +0 -9
  141. package/dist/release-adapters.d.ts +9 -0
  142. package/dist/release-adapters.js +81 -4
  143. package/dist/release.d.ts +7 -1
  144. package/dist/release.js +20 -7
  145. package/dist/remote-exec.d.ts +3 -2
  146. package/dist/remote-exec.js +3 -2
  147. package/dist/repos.d.ts +0 -5
  148. package/dist/repos.js +2 -10
  149. package/dist/repository-context.d.ts +0 -2
  150. package/dist/repository-context.js +0 -4
  151. package/dist/restart-certification.d.ts +0 -2
  152. package/dist/restart-certification.js +1 -5
  153. package/dist/result-follow-ups.d.ts +1 -4
  154. package/dist/result-follow-ups.js +4 -8
  155. package/dist/result-review.d.ts +0 -2
  156. package/dist/result-review.js +0 -2
  157. package/dist/retention.d.ts +0 -1
  158. package/dist/retention.js +0 -1
  159. package/dist/review-context.d.ts +0 -1
  160. package/dist/review-context.js +1 -12
  161. package/dist/reviewer.d.ts +0 -16
  162. package/dist/reviewer.js +0 -18
  163. package/dist/run-closeout.js +2 -1
  164. package/dist/scope.d.ts +13 -0
  165. package/dist/scope.js +11 -18
  166. package/dist/scout-browser.js +2 -1
  167. package/dist/serve.js +3 -2
  168. package/dist/server/chrome.d.ts +10 -1
  169. package/dist/server/chrome.js +45 -4
  170. package/dist/server/console.css +60 -137
  171. package/dist/server/flows.js +2 -2
  172. package/dist/server/http.d.ts +2 -0
  173. package/dist/server/http.js +5 -0
  174. package/dist/server/pages.js +2 -2
  175. package/dist/server/people-tokens.js +3 -4
  176. package/dist/server/remote.js +1 -6
  177. package/dist/server/render-chat.d.ts +9 -2
  178. package/dist/server/render-chat.js +53 -7
  179. package/dist/server/render-pages.d.ts +28 -3
  180. package/dist/server/render-pages.js +78 -9
  181. package/dist/server/render-results.d.ts +0 -6
  182. package/dist/server/render-results.js +3 -9
  183. package/dist/server/render-tasks.js +6 -6
  184. package/dist/server/route-table.d.ts +0 -4
  185. package/dist/server/route-table.js +1 -6
  186. package/dist/server/settings.js +43 -22
  187. package/dist/server/task-views.js +1 -1
  188. package/dist/server/tasks.js +10 -7
  189. package/dist/setup-guide.d.ts +2 -2
  190. package/dist/skills.d.ts +0 -6
  191. package/dist/skills.js +0 -8
  192. package/dist/slack-chat.d.ts +1 -7
  193. package/dist/slack-chat.js +5 -18
  194. package/dist/slack-settings.d.ts +2 -6
  195. package/dist/slack-settings.js +10 -38
  196. package/dist/slack.js +12 -23
  197. package/dist/sleep.d.ts +2 -0
  198. package/dist/sleep.js +2 -0
  199. package/dist/store.d.ts +11 -74
  200. package/dist/store.js +36 -179
  201. package/dist/structured-output.d.ts +0 -1
  202. package/dist/structured-output.js +0 -1
  203. package/dist/subagent-work.d.ts +0 -2
  204. package/dist/subagent-work.js +0 -2
  205. package/dist/subagents.d.ts +1 -3
  206. package/dist/subagents.js +20 -33
  207. package/dist/subscription-chat.d.ts +1 -3
  208. package/dist/subscription-chat.js +2 -3
  209. package/dist/task-sizing.d.ts +1 -4
  210. package/dist/task-sizing.js +3 -10
  211. package/dist/teams-chat.js +4 -11
  212. package/dist/teams.js +12 -19
  213. package/dist/teardown-git.d.ts +22 -0
  214. package/dist/teardown-git.js +136 -2
  215. package/dist/telegram-lead.js +2 -2
  216. package/dist/telegram.js +1 -1
  217. package/dist/token-notices.d.ts +0 -11
  218. package/dist/token-notices.js +1 -5
  219. package/dist/tool-broker.d.ts +12 -0
  220. package/dist/tool-broker.js +66 -2
  221. package/dist/tool-secret-store.d.ts +0 -3
  222. package/dist/tool-secret-store.js +2 -11
  223. package/dist/toolroll-update.d.ts +0 -2
  224. package/dist/toolroll-update.js +2 -2
  225. package/dist/unfenced-test-support.d.ts +2 -0
  226. package/dist/unfenced-test-support.js +11 -0
  227. package/dist/verification-evidence.d.ts +0 -1
  228. package/dist/verification-evidence.js +0 -1
  229. package/dist/webhooks.d.ts +0 -1
  230. package/dist/webhooks.js +0 -1
  231. package/dist/workspace-ui.d.ts +0 -8
  232. package/dist/workspace-ui.js +1 -6
  233. package/dist/writes-mode.d.ts +9 -0
  234. package/dist/writes-mode.js +8 -0
  235. package/package.json +5 -1
  236. package/dist/contracts/native-census.d.ts +0 -42
  237. package/dist/contracts/native-census.js +0 -36
  238. package/dist/process-recovery-anchor.d.ts +0 -75
  239. package/dist/process-recovery-anchor.js +0 -188
  240. package/dist/process-recovery-coalition.d.ts +0 -25
  241. package/dist/process-recovery-coalition.js +0 -105
  242. package/dist/process-recovery-eligibility.d.ts +0 -179
  243. package/dist/process-recovery-eligibility.js +0 -174
  244. package/dist/process-recovery-native.d.ts +0 -161
  245. package/dist/process-recovery-native.js +0 -595
  246. package/dist/process-recovery-provenance.d.ts +0 -163
  247. package/dist/process-recovery-provenance.js +0 -292
  248. package/dist/process-recovery-services.d.ts +0 -56
  249. package/dist/process-recovery-services.js +0 -191
  250. package/dist/process-recovery-settlement.d.ts +0 -32
  251. package/dist/process-recovery-settlement.js +0 -133
  252. package/dist/process-recovery.d.ts +0 -41
  253. package/dist/process-recovery.js +0 -91
  254. package/dist/session-brief.d.ts +0 -8
  255. package/dist/session-brief.js +0 -24
  256. package/dist/session-cli.d.ts +0 -42
  257. package/dist/session-cli.js +0 -271
  258. package/dist/session-contract.d.ts +0 -183
  259. package/dist/session-contract.js +0 -122
  260. package/dist/session-remote.d.ts +0 -19
  261. package/dist/session-remote.js +0 -86
  262. package/dist/session-service.d.ts +0 -22
  263. package/dist/session-service.js +0 -157
  264. package/dist/team-cli.js +0 -692
package/dist/invoke.js CHANGED
@@ -29,19 +29,16 @@ import { witnessedRunner } from "./process-custody.js";
29
29
  import { underStopWatch } from "./task-control.js";
30
30
  import { childDatabaseEnv, isolatedChildDatabase, removeChildDatabase as removeAgentDatabase } from "./child-database.js";
31
31
  import { RunSignInError, noToolsArgs, prepareRunTools } from "./project-tools.js";
32
- import { mkdirSync, readFileSync, realpathSync } from "node:fs";
33
- import { homedir } from "node:os";
32
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
33
+ import { homedir, tmpdir } from "node:os";
34
34
  import { dirname, join } from "node:path";
35
35
  import { poolRootOf } from "./worktree.js";
36
36
  import { BRIDGE_DIR } from "./agent-fence.js";
37
- import { agentFence, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
37
+ import { agentFence, buildCacheEnv, buildCacheFolder, grantFolder, writeAllowlist, WRITES_DEFAULT, CheckoutBindingError, trustedWorktreeBinding, checkoutGitEnv, HARDENED_GIT_ARGS, codexFenceArgv, fenceGrants, fencedGitEnv, linuxFenceAvailable, linuxFenced, macosFenceAvailable, macosFenced, NO_GRANTS, runFolders, taskBoundary } from "./agent-fence.js";
38
38
  import { proxyEnv, providerOnlyNetwork, startEgressProxy } from "./egress.js";
39
39
  import { bridgeFits, startEgressBridge } from "./egress-bridge.js";
40
40
  import { claudeConfigDir, claudeDefaults, codexDefaults, codexHomeDir, isolatedCodexHome } from "./provider-home.js";
41
41
  import { billingOf, claudeBillingFrom, seenBilling } from "./spend.js";
42
- /** The claude binary name — kept for the legacy quota rows and tests that
43
- * describe history; new code carries a resolved AgentSpec instead. */
44
- export const PROVIDER_BINARY = "claude";
45
42
  /** "Within seconds": how soon a silent failed run must end for its stderr to
46
43
  * be read for the sign-in signal. */
47
44
  const AUTH_EARLY_EXIT_MS = 30_000;
@@ -64,6 +61,11 @@ function claudeSettingsAuthProblem(configDir) {
64
61
  catch (error) {
65
62
  if (error.code === "ENOENT")
66
63
  return null;
64
+ // A sandbox around the plane (a project check's fence, say) may hide the file while the CLI still signs in from its
65
+ // keychain. Isolated builds never load it, and a login that needed it fails within seconds and is reported as one.
66
+ const code = error.code;
67
+ if (code === "EPERM" || code === "EACCES")
68
+ return null;
67
69
  return "Can't read your Claude settings.json to check sign-in. Restore access to that file and try again. Isolated builds do not load it.";
68
70
  }
69
71
  let settings;
@@ -239,7 +241,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
239
241
  // re-supply survives resolveChildEnv's chat-key strip); in
240
242
  // "subscription" mode the key is STRIPPED from the child so the CLI's
241
243
  // own login is used — its key is retained, just not handed over.
242
- const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, ...runOptions } = options;
244
+ const { runner, clock: _clock, versionProbe: _probe, keyHome, timeoutMs: _hardTimeout, idleTimeoutMs: _idleTimeout, stdin: _callerStdin, accumulateUsage: _accumulateUsage, liveInput: _liveInput, acpCheck: _acpCheck, network: launchNetwork, writes: launchWrites, ...runOptions } = options;
243
245
  // The live setting is read STRICTLY (atomic authority closure): a
244
246
  // present mode file that says neither word is a value-shaped refusal
245
247
  // before any stamp or process — never the subscription default a
@@ -300,12 +302,21 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
300
302
  // The project's tools (v80): exactly its MCP servers, and none of the
301
303
  // operator's global or a repository's own, served by this launch's broker
302
304
  // (tool-broker.ts), which alone holds their secrets. A review keeps its isolation.
305
+ // Set once the fence is known: a fenced build's servers never start outside a fence.
306
+ let serverMustBeFenced = false;
307
+ // What the build writes (its checkout, its project's caches): a tool server never runs from there, nor reads it at all.
308
+ const serverUntrusted = workspace === null ? [] : toolUntrusted(store, workspace, keyHome);
303
309
  const launchTools = invocation.phase === "review" ? null : await runTools(store, runId, spec, options.keyHome, clock, invocation.readOnlyTools, invocation.researchWithheld, containment, {
304
310
  env: { ...process.env, ...(runOptions.env ?? {}) },
305
311
  omitEnv: [...(runOptions.omitEnv ?? []), ...ALL_CREDENTIAL_ENV],
306
312
  pass: childDatabaseEnv(isolatedDb.file),
307
- ...(workspace === null ? {} : { cwd: workspace }),
313
+ // A tool server starts in a folder the build can't write, and never runs the build's own bytes (arc 2).
314
+ cwd: keyHome ?? homedir(),
315
+ untrusted: serverUntrusted,
308
316
  wrap: () => serverFence,
317
+ // A fenced build's tool servers start only in a fence too, unless an instance operator allowed unconfined agents.
318
+ unwrapped: () => serverMustBeFenced && !store.unfencedAgentsAllowed()
319
+ ? "This machine can't confine the tool's server (no macOS sandbox, no working bubblewrap), so it was not started." : null,
309
320
  });
310
321
  if (launchTools !== null && "refused" in launchTools) {
311
322
  removeAgentDatabase(isolatedDb.dir);
@@ -333,7 +344,7 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
333
344
  // tool servers outside its command sandbox, so its own launch folder is
334
345
  // fenced too; Claude reads its config from there, inside the sandbox.
335
346
  const ownTools = tools?.privateDir === undefined ? null : realpathSync(tools.privateDir);
336
- const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools);
347
+ const fenceOf = invocation.phase === "review" ? { fence: [], fileTools: [], grants: NO_GRANTS, gitPaths: [], cache: undefined, problem: undefined } : runFence(store, runId, spec.provider, options.keyHome, codexHarness ? null : ownTools, launchWrites ?? WRITES_DEFAULT);
337
348
  if (fenceOf.problem !== undefined) {
338
349
  tools?.cleanup();
339
350
  codexHome?.cleanup();
@@ -372,12 +383,16 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
372
383
  // A tool server the broker starts is fenced by what this platform supports, whatever the provider: Codex's own
373
384
  // profile fences only its agent's commands, and the broker starts servers outside it.
374
385
  if (fence.length > 0) {
375
- // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network.
376
- const serverGrants = () => { const { network: _agentOnly, ...rest } = grants; return rest; };
386
+ // A tool server keeps its own reach (it holds its tool's secrets, not the agent's code): never the agent's network,
387
+ // nor its write allowlist (a server keeps its own state where it always has).
388
+ const serverGrants = () => { const { network: _agentOnly, writes: _agentWrites, ...rest } = grants; return rest; };
389
+ // Nor read what the build wrote: however its arguments are spelled, no byte of the checkout or caches can load.
390
+ const serverDenies = [...fence, ...serverUntrusted.filter(path => existsSync(path)).map(path => realpathSync(path))];
377
391
  if (macosFenceAvailable())
378
- serverFence = (file, args) => macosFenced(file, args, fence, serverGrants());
392
+ serverFence = (file, args) => macosFenced(file, args, serverDenies, serverGrants());
379
393
  else if (linuxFenceAvailable())
380
- serverFence = (file, args) => linuxFenced(file, args, fence, serverGrants());
394
+ serverFence = (file, args) => linuxFenced(file, args, serverDenies, serverGrants());
395
+ serverMustBeFenced = true;
381
396
  }
382
397
  // No silent fallback (arc 1): a build this machine can't confine at the OS — Claude reduced to its own file-tool
383
398
  // rules, Gemini to nothing — doesn't start unless the instance operator allowed unconfined agents here. An injected
@@ -457,6 +472,10 @@ export async function invokeAgent(store, runId, spec, invocation, options) {
457
472
  ...(egress === null || !("policy" in network) ? {} : proxyEnv(egress, network.policy.loopback)),
458
473
  // Its own temp folder, where its tools' sockets may live (the only one its fence admits besides its checkout).
459
474
  ...(runTemp === null ? {} : { TMPDIR: runTemp, TMP: runTemp, TEMP: runTemp }),
475
+ // Claude's own working folder goes there too (it is otherwise the shared /tmp/claude-<uid>).
476
+ ...(runTemp === null || spec.provider !== "claude" ? {} : { CLAUDE_CODE_TMPDIR: runTemp }),
477
+ // A workspace build's package managers keep their caches in its project's own folder (arc 2).
478
+ ...(fenceOf.cache === undefined || grants.writes === undefined ? {} : buildCacheEnv(fenceOf.cache)),
460
479
  // This key is deliberately last: no caller may point an agent back at
461
480
  // the live control database through a generic RunOptions override.
462
481
  ...childDatabaseEnv(isolatedDb.file),
@@ -906,7 +925,7 @@ function linkedFor(store, worktree) {
906
925
  /** The paths this run's agent may not reach: the state folder around the live database (but not its own worktree),
907
926
  * ~/.toolroll (or its older name), its task boundary and other launches' private folders; what the fence gives back
908
927
  * (FenceGrants); and what only Claude's file tools refuse. */
909
- function runFence(store, runId, provider, keyHome, ownTools) {
928
+ function runFence(store, runId, provider, keyHome, ownTools, writes = WRITES_DEFAULT) {
910
929
  try {
911
930
  // A plane always has a database file; an in-memory store (a test) has nothing to fence.
912
931
  const databaseFile = store.databaseFile();
@@ -921,22 +940,128 @@ function runFence(store, runId, provider, keyHome, ownTools) {
921
940
  const binding = worktree === null || row === null ? null : trustedWorktreeBinding(row.repo, worktree, poolRoots);
922
941
  const boundary = worktree === null ? null : taskBoundary({ worktree, provider, ...home, binding });
923
942
  const folders = runFolders(ownTools);
943
+ // A build with a checkout writes only its workspace unless its approval said anywhere (arc 2): its checkout and
944
+ // Git data, its launch folder, its project's caches and the provider state its CLI must write.
945
+ const allowed = worktree === null || row === null || writes !== "workspace" ? null
946
+ : writeAllowlist({ provider, home: keyHome ?? homedir(), worktree, binding, launchFolder: ownTools, cache: buildCacheFolder(row.repo, keyHome ?? homedir()) });
947
+ const fence = agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] });
948
+ const grants = fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home, poolRoots });
924
949
  return {
925
- fence: agentFence({ databaseFile, worktree, ...home, boundary, binding, runFolders: folders }),
950
+ fence,
926
951
  fileTools: boundary?.fileTools ?? [],
927
952
  // The shared install is granted only as the plane linked it: this checkout's project and stamped setup.
928
953
  // Every plane checkout pool is denied around this one: the plane's own beside the database, and the one this
929
954
  // checkout was recorded in (a --pool elsewhere), so another project's worktree, present at launch, is out of reach. Native wrappers also cover later creations.
930
- grants: fenceGrants({ databaseFile, worktree, boundary, runFolders: folders, linked: linkedFor(store, worktree), binding, ...home,
931
- poolRoots }),
955
+ grants: allowed === null ? grants : { ...grants, writes: allowed.writes, readOnly: [...new Set([...grants.readOnly, ...allowed.readOnly])].sort() },
932
956
  // The checkout's own Git data, which a private /tmp gives back beside the checkout (agent-fence.ts linuxFenced).
933
957
  gitPaths: binding === null ? [] : [binding.gitdir, join(binding.main, ".git")],
958
+ ...(allowed === null || row === null ? {} : { cache: buildCacheFolder(row.repo, keyHome ?? homedir()) }),
934
959
  };
935
960
  }
936
961
  catch (error) {
937
962
  return { fence: [], fileTools: [], grants: NO_GRANTS, problem: error instanceof CheckoutBindingError ? error.message : "The build fence could not be prepared." };
938
963
  }
939
964
  }
965
+ /**
966
+ * A build's setup and check commands, run in its fence (arc 2): the same denies as its agent's (Toolroll's state,
967
+ * credentials, other projects and runs), its write allowlist when its approval sealed one (its checkout and Git data,
968
+ * its project's caches, and a temp folder of the command's own), and its package managers pointed at those caches.
969
+ * Their network stays as it was. A machine without an OS fence runs them as before; a fence that can't be prepared
970
+ * (a checkout whose binding no longer proves) runs nothing.
971
+ */
972
+ export function fencedCommands(store, runId, writes, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
973
+ // An injected runner (tests) starts no real command, as an injected agent starts no real agent (invokeAgent).
974
+ if (inner !== runCommand)
975
+ return inner;
976
+ return async (file, args, options) => {
977
+ // As a build itself: where nothing can confine them, they run only when an instance operator allowed unconfined agents.
978
+ if (!available()) {
979
+ if (store.unfencedAgentsAllowed())
980
+ return inner(file, args, options);
981
+ return { code: 1, stdout: "", stderr: "This machine can't confine the build's commands (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
982
+ }
983
+ const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
984
+ try {
985
+ const temp = join(own, "tmp");
986
+ mkdirSync(temp, { mode: 0o700 });
987
+ const fenced = runFence(store, runId, "command", undefined, own, writes);
988
+ if (fenced.problem !== undefined)
989
+ return { code: 1, stdout: "", stderr: `${fenced.problem}\n`, timedOut: false, notFound: false };
990
+ if (fenced.fence.length === 0)
991
+ return inner(file, args, options);
992
+ // A self-hosted check's own database (child-database.ts) is the command's to write too.
993
+ const database = options?.env?.["TOOLROLL_DB"];
994
+ const checkData = database === undefined || fenced.grants.writes === undefined ? null : grantFolder(dirname(database), false);
995
+ const grants = checkData === null ? fenced.grants : { ...fenced.grants, writes: { ...fenced.grants.writes, subpaths: [...fenced.grants.writes.subpaths, checkData] } };
996
+ return await inner(file, args, {
997
+ ...options,
998
+ fence: fenced.fence,
999
+ fenceGrants: grants,
1000
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(fenced.cache === undefined || fenced.grants.writes === undefined ? {} : buildCacheEnv(fenced.cache)) },
1001
+ });
1002
+ }
1003
+ finally {
1004
+ rmSync(own, { recursive: true, force: true });
1005
+ }
1006
+ };
1007
+ }
1008
+ /**
1009
+ * A project's checks run in a checkout of a result's commit made for them (result-follow-ups.ts, batch-checks.ts), in
1010
+ * the same fence as a build's commands: Toolroll's state, credentials, other projects and runs denied; under the
1011
+ * project's writes setting, only that checkout, its Git data, its own temp folder, the project's caches and its check
1012
+ * database writable. Where nothing can confine them, they run only when an instance operator allowed unconfined agents.
1013
+ */
1014
+ export function fencedCheckoutCommands(store, repo, checkout, inner, available = () => macosFenceAvailable() || linuxFenceAvailable()) {
1015
+ if (inner !== runCommand)
1016
+ return inner;
1017
+ return async (file, args, options) => {
1018
+ if (!available()) {
1019
+ if (store.unfencedAgentsAllowed())
1020
+ return inner(file, args, options);
1021
+ return { code: 1, stdout: "", stderr: "This machine can't confine the project's checks (no macOS sandbox, no working bubblewrap), so they didn't run. Install bubblewrap, or an instance operator allows unconfined agents with `toolroll config unconfined-agents allow`.\n", timedOut: false, notFound: false };
1022
+ }
1023
+ const databaseFile = store.databaseFile();
1024
+ if (databaseFile === null)
1025
+ return inner(file, args, options);
1026
+ const own = realpathSync(mkdtempSync(join(tmpdir(), "so-cmd-")));
1027
+ try {
1028
+ const temp = join(own, "tmp");
1029
+ mkdirSync(temp, { mode: 0o700 });
1030
+ let binding;
1031
+ try {
1032
+ binding = trustedWorktreeBinding(repo, checkout);
1033
+ }
1034
+ catch {
1035
+ return { code: 1, stdout: "", stderr: "The check's checkout couldn't be bound to its project, so nothing ran.\n", timedOut: false, notFound: false };
1036
+ }
1037
+ const poolRoots = [join(dirname(databaseFile), "worktrees"), ...store.listWorktrees().map(record => poolRootOf(record.path, record.repo))];
1038
+ const boundary = taskBoundary({ worktree: checkout, provider: "command", binding });
1039
+ const folders = runFolders(own);
1040
+ const cache = buildCacheFolder(repo);
1041
+ const allowed = store.projectWrites(repo) !== "workspace" ? null : writeAllowlist({ provider: "command", home: homedir(), worktree: checkout, binding, launchFolder: own, cache });
1042
+ const database = options?.env?.["TOOLROLL_DB"];
1043
+ const checkData = allowed === null || database === undefined ? null : grantFolder(dirname(database), false);
1044
+ const base = fenceGrants({ databaseFile, worktree: checkout, boundary, runFolders: folders, linked: linkedFor(store, checkout), binding, poolRoots });
1045
+ const grants = allowed === null ? base
1046
+ : { ...base, readOnly: [...new Set([...base.readOnly, ...allowed.readOnly])].sort(), writes: { ...allowed.writes, subpaths: [...allowed.writes.subpaths, ...(checkData === null ? [] : [checkData])] } };
1047
+ return await inner(file, args, {
1048
+ ...options,
1049
+ fence: agentFence({ databaseFile, worktree: checkout, boundary, binding, runFolders: folders, extra: allowed?.deny ?? [] }),
1050
+ fenceGrants: grants,
1051
+ env: { ...(options?.env ?? {}), TMPDIR: temp, TMP: temp, TEMP: temp, ...(allowed === null ? {} : buildCacheEnv(cache)) },
1052
+ });
1053
+ }
1054
+ finally {
1055
+ rmSync(own, { recursive: true, force: true });
1056
+ }
1057
+ };
1058
+ }
1059
+ /** What a build writes that a tool server must never run from: its checkout and its project's caches. A checkout with
1060
+ * no record never gets this far: its fence refuses the launch (runFence) before any tool server can start. */
1061
+ function toolUntrusted(store, workspace, keyHome) {
1062
+ const row = store.getWorktree(workspace);
1063
+ return [workspace, ...(row === null ? [] : [buildCacheFolder(row.repo, keyHome ?? homedir())])];
1064
+ }
940
1065
  /** The operator's own model choices a build routed with no model keeps (provider-home.ts). */
941
1066
  function inheritedDefaults(provider, env, home) {
942
1067
  if (provider === "claude")
@@ -0,0 +1,2 @@
1
+ /** A plain object: not null, not an array. */
2
+ export declare const isRecord: (value: unknown) => value is Record<string, unknown>;
@@ -0,0 +1,2 @@
1
+ /** A plain object: not null, not an array. */
2
+ export const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
@@ -1,5 +1,5 @@
1
1
  import { type SharedActionOptions } from './chat-actions.js';
2
- import { type CoordinatorProposal, type LeadProposal, type Store } from "./store.js";
2
+ import { type LeadProposal, type Store } from "./store.js";
3
3
  import type { VerifiedApprover } from "./principal.js";
4
4
  import { type TeamChatProviderResolver } from './team-chat-authorization.js';
5
5
  export type ProposalKind = LeadProposal["kind"];
@@ -78,7 +78,3 @@ export declare function dismissLeadProposal(store: Store, who: VerifiedApprover,
78
78
  */
79
79
  export declare function confirmCoordinatorProposal(store: Store, who: VerifiedApprover, proposalId: number, now: Date, options: DoorOptions): DoorOutcome;
80
80
  export declare function dismissCoordinatorProposal(store: Store, who: VerifiedApprover, proposalId: number, now: Date): boolean;
81
- /** Which door renders a coordinator proposal for this reader: the repo must be admitted. */
82
- export declare function coordinatorProposalVisible(who: {
83
- repos: readonly string[];
84
- }, proposal: CoordinatorProposal): boolean;
@@ -260,10 +260,6 @@ export function dismissCoordinatorProposal(store, who, proposalId, now) {
260
260
  return store.casCoordinatorProposal(proposalId, "pending", "dismissed", who.name, null, now);
261
261
  });
262
262
  }
263
- /** Which door renders a coordinator proposal for this reader: the repo must be admitted. */
264
- export function coordinatorProposalVisible(who, proposal) {
265
- return who.repos.includes(proposal.repo);
266
- }
267
263
  /**
268
264
  * The shared executor: one kind, one payload, the operator's own act
269
265
  * through the primitive that owns it. Module-private (v3 review, finding
@@ -1,5 +1,4 @@
1
1
  import { sweepLine, sweepTable } from "./lead-sweep.js";
2
- import { TEXT_LIMITS } from "./text-limits.js";
3
2
  import { connectionsOf, oneClickOf } from "./mcp-connect.js";
4
3
  import { checkLevelFromWords, isCheckLevel } from "./check-levels.js";
5
4
  import { policyParts } from "./policy.js";
@@ -39,9 +38,9 @@ import { CHAT_TASK_ACTIONS, chatTaskRun, chatTaskStamp, isChatTaskAction } from
39
38
  import { Buffer } from "node:buffer";
40
39
  import { LEAD_ASK_OTHER } from "./store.js";
41
40
  import { hasDisguisedText, hasForbiddenControls } from "./decision.js";
42
- import { readVerifiedArtifact, readVerifiedReport, scanForSecrets } from "./evidence.js";
41
+ import { readVerifiedArtifact, scanForSecrets } from "./evidence.js";
43
42
  import { parseAcceptanceCriteria } from "./scope.js";
44
- import { diagnoseTaskDispatch, withDispatchDiagnoses } from "./dispatch.js";
43
+ import { diagnoseTaskDispatch } from "./dispatch.js";
45
44
  import { agentChoicesFor, routeOfTask, INSTALLATION_SCOPE } from "./agentconfig.js";
46
45
  import { isNewModel, modelWords, priceWords, runtimeStates, seenModels } from "./model-catalog.js";
47
46
  import { agentsSummary, chosenWords, PHASES, postureWords, routeProblems, sameSpec, sizeSourceWords, sizeWords, specWords } from "./phase-routing.js";
@@ -55,7 +54,7 @@ import { planInChat } from "./chat-decide.js";
55
54
  import { flowDecisionAt } from "./telegram-flow.js";
56
55
  import { flowPersonOf } from "./flow-send.js";
57
56
  import { flowInsights } from "./flow-insights.js";
58
- import { describeTrigger, FLOW_TRIGGER_KINDS, triggerConfigOf } from "./flow-triggers.js";
57
+ import { describeTrigger, triggerConfigOf } from "./flow-triggers.js";
59
58
  export const LEAD_MAX_PROPOSALS_PER_TURN = 5;
60
59
  export { reportSummaryFor } from "./report-summary.js";
61
60
  import { reportSummaryFor } from "./report-summary.js";
@@ -63,8 +62,6 @@ import { labelOf, nameOf, summaryOf, zonesOf } from "./subagent-admin.js";
63
62
  import { SUBAGENT_TEMPLATES, withSection } from "./subagents.js";
64
63
  import { callOutcome, callWords, defaultRule, ruleWords } from "./subagent-tools.js";
65
64
  import { deskOf, routinesOf } from "./subagent-desk.js";
66
- import { KITS } from "./kits.js";
67
- import { STARTER_IDS } from "./flow-starters.js";
68
65
  import { undoFor, weekOf, weekWords } from "./subagent-week.js";
69
66
  import { PERSON_ID, personEntry, teamRoomOf } from "./lead-people.js";
70
67
  import { aboutYouOf, checkAboutYouLine } from "./lead-about.js";
@@ -10,7 +10,6 @@ export type LiveDetailSetting = {
10
10
  };
11
11
  /** Whether this project's live conversations show commands. Unknown or absent is off. */
12
12
  export declare function liveCommandsShown(store: Store, repo: string | null): boolean;
13
- export declare function liveDetailSetting(store: Store, repo: string): LiveDetailSetting | null;
14
13
  /** Save the setting and its ledger line in one transaction. The caller has proved who is asking (and, to turn it on, their password). */
15
14
  export declare function setLiveCommands(store: Store, change: {
16
15
  repo: string;
@@ -26,12 +26,6 @@ export function liveCommandsShown(store, repo) {
26
26
  return false;
27
27
  }
28
28
  }
29
- export function liveDetailSetting(store, repo) {
30
- const row = store.handle.prepare("SELECT repo, commands, updated_by, updated_at FROM live_detail_setting WHERE repo = ?").get(repo);
31
- if (row === undefined)
32
- return null;
33
- return { repo: String(row["repo"]), commands: Number(row["commands"]) === 1, updatedBy: String(row["updated_by"]), updatedAt: String(row["updated_at"]) };
34
- }
35
29
  /** Save the setting and its ledger line in one transaction. The caller has proved who is asking (and, to turn it on, their password). */
36
30
  export function setLiveCommands(store, change, now) {
37
31
  store.transact(() => {
@@ -6,8 +6,6 @@ export declare const MAX_LIVE_TERMINALS = 8;
6
6
  export declare const MAX_KEPT_TERMINALS = 16;
7
7
  /** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
8
8
  export declare const MAX_VIEWERS = 4;
9
- /** Raw output kept per session for a page that attaches later. */
10
- export declare const SCROLLBACK_BYTES: number;
11
9
  /** After SIGHUP, the terminal's processes have this long before SIGKILL. */
12
10
  export declare const CLOSE_GRACE_MS = 2000;
13
11
  /** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
@@ -54,15 +52,24 @@ export declare const ttyName: (path: unknown) => string | null;
54
52
  export declare function pendingInputOf(pty: unknown): number;
55
53
  /** node-pty, if this install has it (an optional dependency with prebuilt binaries); null when it doesn't load. */
56
54
  export declare function loadPtySpawn(): PtySpawn | null;
57
- /** Raw output, oldest dropped first, never more than `cap` bytes. */
58
- export declare class Scrollback {
59
- private readonly cap;
60
- private chunks;
61
- private size;
62
- constructor(cap?: number);
63
- push(chunk: Buffer): void;
64
- bytes(): Buffer;
65
- get length(): number;
55
+ /** How many lines above the screen a terminal keeps for a page that attaches later. */
56
+ export declare const SCREEN_SCROLLBACK_LINES = 5000;
57
+ /**
58
+ * The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
59
+ * that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
60
+ * cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
61
+ * could split an escape sequence or redraw a full-screen program garbled.
62
+ */
63
+ export declare class ScreenMirror {
64
+ private readonly lines;
65
+ private readonly terminal;
66
+ private readonly serializer;
67
+ constructor(cols: number, rows: number, lines?: number);
68
+ write(data: string): void;
69
+ resize(cols: number, rows: number): void;
70
+ /** The screen as of this call (after everything written before it), handed to `done` once parsed. */
71
+ snapshot(done: (screen: string) => void): void;
72
+ dispose(): void;
66
73
  }
67
74
  /** One attached page. The host never waits on it. */
68
75
  export type TerminalViewer = {
@@ -138,7 +145,8 @@ export type TerminalHostOptions = {
138
145
  /** Start, close and remove, for the event log (never input or output). */
139
146
  record?: (event: "start" | "close" | "remove" | "exit", session: TerminalSummary) => void;
140
147
  maxSessions?: number;
141
- scrollbackBytes?: number;
148
+ /** Lines above the screen a terminal keeps for a page that attaches later (SCREEN_SCROLLBACK_LINES). */
149
+ scrollbackLines?: number;
142
150
  closeGraceMs?: number;
143
151
  ledger?: TerminalLedger;
144
152
  /** The process table (tests stand a failing one in); null when it can't be read. */
@@ -175,7 +183,10 @@ export declare class TerminalHost {
175
183
  cols: number;
176
184
  rows: number;
177
185
  }, admit?: () => string | null): Promise<TerminalSummary>;
178
- /** Attach a page: it gets the scrollback, then `hello`, then live output. Returns its detach. */
186
+ /**
187
+ * Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
188
+ * writes meanwhile is held and sent after the screen, in order. Returns its detach.
189
+ */
179
190
  attach(id: string, viewer: TerminalViewer): () => void;
180
191
  /** How many pages are attached now. */
181
192
  viewerCount(id: string): number;
@@ -13,14 +13,13 @@ import { existsSync, mkdirSync, readlinkSync, realpathSync, statSync } from "nod
13
13
  import { delimiter, isAbsolute, join } from "node:path";
14
14
  import { codingEnvironment } from "./coding-provider.js";
15
15
  import { teardownGit, refusal } from "./teardown-git.js";
16
+ import { sleep } from "./sleep.js";
16
17
  export const TERMINAL_PROGRAMS = ["claude", "codex", "shell"];
17
18
  /** At most this many running sessions per console, and this many kept at all (ended ones count until removed). */
18
19
  export const MAX_LIVE_TERMINALS = 8;
19
20
  export const MAX_KEPT_TERMINALS = 16;
20
21
  /** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
21
22
  export const MAX_VIEWERS = 4;
22
- /** Raw output kept per session for a page that attaches later. */
23
- export const SCROLLBACK_BYTES = 1024 * 1024;
24
23
  /** After SIGHUP, the terminal's processes have this long before SIGKILL. */
25
24
  export const CLOSE_GRACE_MS = 2_000;
26
25
  /** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
@@ -70,37 +69,45 @@ export function loadPtySpawn() {
70
69
  return null;
71
70
  }
72
71
  }
73
- /** Raw output, oldest dropped first, never more than `cap` bytes. */
74
- export class Scrollback {
75
- cap;
76
- chunks = [];
77
- size = 0;
78
- constructor(cap = SCROLLBACK_BYTES) {
79
- this.cap = cap;
80
- }
81
- push(chunk) {
82
- if (chunk.length >= this.cap) {
83
- this.chunks = [chunk.subarray(chunk.length - this.cap)];
84
- this.size = this.cap;
85
- return;
86
- }
87
- this.chunks.push(chunk);
88
- this.size += chunk.length;
89
- while (this.size > this.cap) {
90
- const first = this.chunks[0];
91
- const over = this.size - this.cap;
92
- if (first.length <= over) {
93
- this.chunks.shift();
94
- this.size -= first.length;
95
- }
96
- else {
97
- this.chunks[0] = first.subarray(over);
98
- this.size -= over;
99
- }
72
+ /** How many lines above the screen a terminal keeps for a page that attaches later. */
73
+ export const SCREEN_SCROLLBACK_LINES = 5000;
74
+ const require = createRequire(import.meta.url);
75
+ /**
76
+ * The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
77
+ * that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
78
+ * cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
79
+ * could split an escape sequence or redraw a full-screen program garbled.
80
+ */
81
+ export class ScreenMirror {
82
+ lines;
83
+ terminal;
84
+ serializer;
85
+ constructor(cols, rows, lines = SCREEN_SCROLLBACK_LINES) {
86
+ this.lines = lines;
87
+ const { Terminal } = require("@xterm/headless");
88
+ const { SerializeAddon } = require("@xterm/addon-serialize");
89
+ this.terminal = new Terminal({ cols, rows, scrollback: lines, allowProposedApi: true });
90
+ this.serializer = new SerializeAddon();
91
+ this.terminal.loadAddon(this.serializer);
92
+ }
93
+ write(data) { this.terminal.write(data); }
94
+ resize(cols, rows) { try {
95
+ this.terminal.resize(cols, rows);
96
+ }
97
+ catch { /* sizes are clamped before this */ } }
98
+ /** The screen as of this call (after everything written before it), handed to `done` once parsed. */
99
+ snapshot(done) {
100
+ this.terminal.write("", () => { let screen = ""; try {
101
+ screen = this.serializer.serialize({ scrollback: this.lines });
100
102
  }
103
+ catch {
104
+ screen = "";
105
+ } done(screen); });
106
+ }
107
+ dispose() { try {
108
+ this.terminal.dispose();
101
109
  }
102
- bytes() { return Buffer.concat(this.chunks, this.size); }
103
- get length() { return this.size; }
110
+ catch { /* already */ } }
104
111
  }
105
112
  const NO_LEDGER = { reserve() { }, running() { }, release() { }, forget() { } };
106
113
  export class TerminalError extends Error {
@@ -237,7 +244,7 @@ export class TerminalHost {
237
244
  }
238
245
  let resolveExited;
239
246
  const session = { id, owner, generation, repo, program, branch, worktree, startedAt: this.clock().toISOString(), endedAt: null, exit: null, endedBy: null, cleanup: null,
240
- pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, scrollback: new Scrollback(this.options.scrollbackBytes), viewers: new Set(), closing: null,
247
+ pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, screen: new ScreenMirror(cols, rows, this.options.scrollbackLines), viewers: new Set(), closing: null,
241
248
  exited: new Promise(resolve => { resolveExited = resolve; }) };
242
249
  this.sessions.set(id, session);
243
250
  this.ledger.running(id);
@@ -246,7 +253,7 @@ export class TerminalHost {
246
253
  pty.onData(data => {
247
254
  this.noteSoon();
248
255
  const chunk = Buffer.from(data, "utf8");
249
- session.scrollback.push(chunk);
256
+ session.screen.write(data);
250
257
  for (const viewer of session.viewers) {
251
258
  try {
252
259
  viewer.output(chunk);
@@ -280,21 +287,50 @@ export class TerminalHost {
280
287
  this.ledger.release(id, null);
281
288
  }
282
289
  }
283
- /** Attach a page: it gets the scrollback, then `hello`, then live output. Returns its detach. */
290
+ /**
291
+ * Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
292
+ * writes meanwhile is held and sent after the screen, in order. Returns its detach.
293
+ */
284
294
  attach(id, viewer) {
285
295
  const session = this.sessions.get(id);
286
296
  if (session === undefined)
287
297
  throw new TerminalError("No such terminal.");
288
298
  if (session.viewers.size >= MAX_VIEWERS)
289
299
  throw new TerminalError(`This terminal is open in ${MAX_VIEWERS} places already. Close one of them.`);
290
- const replay = session.scrollback.bytes();
291
- if (replay.length > 0)
292
- viewer.output(replay);
293
- viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
294
- if (session.exit !== null)
295
- viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
296
- session.viewers.add(viewer);
297
- return () => { session.viewers.delete(viewer); };
300
+ const held = [];
301
+ let live = false;
302
+ const gate = {
303
+ output: data => { if (live)
304
+ viewer.output(data);
305
+ else
306
+ held.push({ output: data }); },
307
+ control: message => { if (live)
308
+ viewer.control(message);
309
+ else
310
+ held.push({ control: message }); },
311
+ };
312
+ session.viewers.add(gate);
313
+ session.screen.snapshot(screen => {
314
+ if (!session.viewers.has(gate))
315
+ return;
316
+ try {
317
+ if (screen !== "")
318
+ viewer.output(Buffer.from(screen, "utf8"));
319
+ viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
320
+ if (session.exit !== null && !held.some(one => "control" in one && one.control.t === "exit"))
321
+ viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
322
+ for (const one of held) {
323
+ if ("output" in one)
324
+ viewer.output(one.output);
325
+ else
326
+ viewer.control(one.control);
327
+ }
328
+ }
329
+ catch { /* the viewer's own close handles it */ }
330
+ held.length = 0;
331
+ live = true;
332
+ });
333
+ return () => { session.viewers.delete(gate); };
298
334
  }
299
335
  /** How many pages are attached now. */
300
336
  viewerCount(id) { return this.sessions.get(id)?.viewers.size ?? 0; }
@@ -319,6 +355,7 @@ export class TerminalHost {
319
355
  session.pty.resize(session.cols, session.rows);
320
356
  }
321
357
  catch { /* it exited between */ }
358
+ session.screen.resize(session.cols, session.rows);
322
359
  }
323
360
  noteTimer = null;
324
361
  /** Activity (output, typing) usually means a command started: note processes shortly after, at most every 250 ms. */
@@ -368,7 +405,7 @@ export class TerminalHost {
368
405
  }
369
406
  async sweep(session) {
370
407
  const grace = this.options.closeGraceMs ?? CLOSE_GRACE_MS;
371
- const pause = (ms) => new Promise(resolve => setTimeout(resolve, ms));
408
+ const pause = sleep;
372
409
  const leaderGone = () => session.pty === null;
373
410
  let readable = true;
374
411
  const members = () => {
@@ -487,6 +524,7 @@ export class TerminalHost {
487
524
  catch { /* gone */ }
488
525
  }
489
526
  session.viewers.clear();
527
+ session.screen.dispose();
490
528
  const result = await (this.options.git ?? teardownGit())(session.repo, ["worktree", "remove", "--force", "--", session.worktree]);
491
529
  this.options.record?.("remove", this.summary(session));
492
530
  if (result.code !== 0 && existsSync(session.worktree)) {
package/dist/live.d.ts CHANGED
@@ -16,7 +16,6 @@
16
16
  import { type KeyState, type LiveActivityKind, type LiveRecord, type LiveRecorderOptions } from "./live-records.js";
17
17
  export declare const LIVE_LINE_CAP: number;
18
18
  export declare const LIVE_FILE_CAP: number;
19
- export declare const LIVE_CAP_MARKER = "[the live view stopped at its size cap \u2014 the run record continues]";
20
19
  export declare const LIVE_REDACTED_LINE = "[a credential-shaped line was redacted]";
21
20
  /** Finalized runs keep their live file this long for the final drain. */
22
21
  export declare const LIVE_RETAIN_MS: number;
package/dist/live.js CHANGED
@@ -16,10 +16,9 @@
16
16
  import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, statSync, unlinkSync, writeSync } from "node:fs";
17
17
  import { join } from "node:path";
18
18
  import { scanForSecrets } from "./evidence.js";
19
- import { createLiveRecorder, LIVE_CAP_WORDS, parseLiveRecords, redactLiveLines, projectLiveText, serializeLiveRecord } from "./live-records.js";
19
+ import { createLiveRecorder, parseLiveRecords, redactLiveLines, projectLiveText, serializeLiveRecord } from "./live-records.js";
20
20
  export const LIVE_LINE_CAP = 2 * 1024;
21
21
  export const LIVE_FILE_CAP = 4 * 1024 * 1024;
22
- export const LIVE_CAP_MARKER = LIVE_CAP_WORDS;
23
22
  export const LIVE_REDACTED_LINE = "[a credential-shaped line was redacted]";
24
23
  /** Finalized runs keep their live file this long for the final drain. */
25
24
  export const LIVE_RETAIN_MS = 24 * 60 * 60 * 1000;