toolroll 0.9.74 → 0.9.76

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 (119) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/dist/agent-fence.js +8 -0
  3. package/dist/approval-authority.js +6 -2
  4. package/dist/approval-rules-ui.d.ts +11 -0
  5. package/dist/approval-rules-ui.js +13 -1
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/batch-checks.js +3 -1
  9. package/dist/browser/workspace.js +27 -27
  10. package/dist/browser-workspace.d.ts +1 -1
  11. package/dist/builder.js +1 -1
  12. package/dist/chat-acceptance.d.ts +4 -1
  13. package/dist/chat-acceptance.js +13 -3
  14. package/dist/cli.js +1 -1
  15. package/dist/coding-handoff.js +4 -3
  16. package/dist/coding-ui.js +2 -2
  17. package/dist/coding-workspace.js +23 -9
  18. package/dist/contracts/console-api.d.ts +6 -1
  19. package/dist/contracts/console-api.js +11 -0
  20. package/dist/control-ui.d.ts +3 -2
  21. package/dist/control-ui.js +7 -2
  22. package/dist/demo.js +5 -3
  23. package/dist/deploy-gate.d.ts +3 -0
  24. package/dist/deploy-gate.js +3 -2
  25. package/dist/desktop-update.d.ts +4 -1
  26. package/dist/desktop-update.js +43 -9
  27. package/dist/discover.js +6 -1
  28. package/dist/first-run.d.ts +8 -5
  29. package/dist/first-run.js +42 -7
  30. package/dist/flow-card-task.js +3 -2
  31. package/dist/flow-unattended.d.ts +2 -0
  32. package/dist/flow-unattended.js +12 -1
  33. package/dist/git-incident.d.ts +49 -0
  34. package/dist/git-incident.js +69 -0
  35. package/dist/git-views.d.ts +20 -0
  36. package/dist/git-views.js +120 -0
  37. package/dist/grant.js +3 -1
  38. package/dist/integrations.d.ts +4 -3
  39. package/dist/integrations.js +5 -6
  40. package/dist/lead-contract.d.ts +1 -1
  41. package/dist/lead-contract.js +2 -2
  42. package/dist/lead-follow.js +3 -1
  43. package/dist/openrouter-models.d.ts +5 -2
  44. package/dist/openrouter-models.js +7 -4
  45. package/dist/operate.js +9 -1
  46. package/dist/planner.js +5 -1
  47. package/dist/project-knowledge.js +1 -1
  48. package/dist/project-learning.js +65 -7
  49. package/dist/project-tools.js +19 -1
  50. package/dist/pull-request-flow.js +4 -0
  51. package/dist/pull-request-ui.d.ts +1 -1
  52. package/dist/pull-request-ui.js +5 -1
  53. package/dist/push.d.ts +2 -0
  54. package/dist/push.js +24 -19
  55. package/dist/read-only-request.d.ts +4 -0
  56. package/dist/read-only-request.js +15 -0
  57. package/dist/release-adapters.d.ts +24 -2
  58. package/dist/release-adapters.js +136 -10
  59. package/dist/release-authority.d.ts +46 -0
  60. package/dist/release-authority.js +64 -0
  61. package/dist/release-auto.d.ts +153 -0
  62. package/dist/release-auto.js +496 -0
  63. package/dist/release-cli.js +56 -2
  64. package/dist/release-coverage.js +1 -1
  65. package/dist/release.d.ts +105 -1
  66. package/dist/release.js +229 -9
  67. package/dist/repository-context.js +5 -2
  68. package/dist/result-follow-ups.js +23 -2
  69. package/dist/result-review.js +5 -1
  70. package/dist/scope.d.ts +31 -2
  71. package/dist/scope.js +44 -3
  72. package/dist/scout.js +5 -1
  73. package/dist/serve.js +162 -6
  74. package/dist/server/agents-live-filed.d.ts +17 -0
  75. package/dist/server/agents-live-filed.js +97 -0
  76. package/dist/server/agents-live-room.js +4 -1
  77. package/dist/server/chat.js +31 -23
  78. package/dist/server/http.d.ts +5 -0
  79. package/dist/server/last-checks.d.ts +17 -0
  80. package/dist/server/last-checks.js +22 -0
  81. package/dist/server/pages.js +18 -4
  82. package/dist/server/people-tokens.js +7 -4
  83. package/dist/server/render-pages.d.ts +2 -1
  84. package/dist/server/render-pages.js +28 -24
  85. package/dist/server/render-results.d.ts +11 -2
  86. package/dist/server/render-results.js +54 -12
  87. package/dist/server/render-settings.js +3 -3
  88. package/dist/server/render-tasks.d.ts +10 -0
  89. package/dist/server/render-tasks.js +30 -17
  90. package/dist/server/route-table.js +17 -1
  91. package/dist/server/runtime.d.ts +16 -1
  92. package/dist/server/settings.js +111 -31
  93. package/dist/server/task-views.js +8 -1
  94. package/dist/server/tasks.js +11 -2
  95. package/dist/storage-ui.d.ts +2 -0
  96. package/dist/storage-ui.js +1 -1
  97. package/dist/storage.js +2 -1
  98. package/dist/store.d.ts +4 -1
  99. package/dist/store.js +52 -13
  100. package/dist/subagent-work.js +3 -0
  101. package/dist/task-base.js +1 -1
  102. package/dist/task-status.d.ts +28 -1
  103. package/dist/task-status.js +93 -11
  104. package/dist/teardown-git.d.ts +59 -5
  105. package/dist/teardown-git.js +1022 -90
  106. package/dist/telegram-progress.js +4 -4
  107. package/dist/telegram-status.js +8 -3
  108. package/dist/test-infra.d.ts +26 -0
  109. package/dist/test-infra.js +321 -0
  110. package/dist/tool-secret-store.js +8 -0
  111. package/dist/toolroll-update-ui.d.ts +2 -0
  112. package/dist/toolroll-update-ui.js +4 -3
  113. package/dist/toolroll-update.js +10 -9
  114. package/dist/work-index.js +29 -12
  115. package/dist/workspace-ui.d.ts +5 -0
  116. package/dist/workspace-ui.js +38 -10
  117. package/dist/worktree.d.ts +4 -0
  118. package/dist/worktree.js +13 -7
  119. package/package.json +1 -1
package/dist/scope.d.ts CHANGED
@@ -418,7 +418,10 @@ export type ScopeAuthor = "mate" | "coordinator" | "scout" | "lead";
418
418
  /** The authorities a seal can stand on (approval-policy plan §1.5). Anything else stands on nothing. */
419
419
  /** `carried`: a later commit of a release carried under a person's release approval (release-once.md); only the carry
420
420
  * transaction ever writes it. */
421
- export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried";
421
+ /** `auto-check` / `auto-green`: a release task the plane approved itself under the project's "Approve automatically when
422
+ * checks pass" (docs/plans/auto-approve-green.md) — `auto-check` lets its check run, `auto-green` is the release
423
+ * approval once CI and that check passed. Only release-auto.ts writes them; neither is ever a person's. */
424
+ export type ApprovalBasis = "password" | "mode" | "chat" | "grant" | "schedule" | "lead" | "carried" | "auto-check" | "auto-green";
422
425
  export declare const APPROVAL_BASES: readonly ApprovalBasis[];
423
426
  export type Approval = {
424
427
  approved: true;
@@ -865,7 +868,7 @@ export declare function releaseTermsSentence(terms: ReleaseTerms): string;
865
868
  export declare function releaseAsksAgainWords(terms: ReleaseTerms): string[];
866
869
  export { personProof } from "./person-proof.js";
867
870
  import { type ReleaseTerms } from "./contracts/release-terms.js";
868
- import { type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
871
+ import { type AutoEvidence, type ReleaseAuthority, type ReleaseCarry } from "./release-authority.js";
869
872
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
870
873
  export declare const machineActing: () => boolean;
871
874
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -952,3 +955,29 @@ export declare function mainDebtProblem(store: Store, repo: string, taskId: stri
952
955
  * chat it is approved only through the release card.
953
956
  */
954
957
  export declare function deployGateScope(scope: Pick<Scope, "candidate" | "releaseTerms">): boolean;
958
+ /**
959
+ * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
960
+ * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
961
+ * this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
962
+ * and the person who turned it on still an approver there. Never a person's approval.
963
+ */
964
+ export declare function autoReleaseApprovalOf(store: Store, taskId: string, kind: "auto-check" | "auto-green"): {
965
+ ok: true;
966
+ by: string;
967
+ at: string;
968
+ evidence: AutoEvidence;
969
+ } | {
970
+ ok: false;
971
+ why: string;
972
+ };
973
+ /** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
974
+ * readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
975
+ export declare function releaseApprovalOf(store: Store, taskId: string, _now?: Date): {
976
+ ok: true;
977
+ by: string;
978
+ at: string;
979
+ method: string;
980
+ } | {
981
+ ok: false;
982
+ why: string;
983
+ };
package/dist/scope.js CHANGED
@@ -571,7 +571,7 @@ export function canonicalAcceptance(criteria) {
571
571
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
572
572
  .map(c => ({ id: c.id, statement: c.statement.trim(), evidence: [...c.evidence].sort() }));
573
573
  }
574
- export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried"];
574
+ export const APPROVAL_BASES = ["password", "mode", "chat", "grant", "schedule", "lead", "carried", "auto-check", "auto-green"];
575
575
  /**
576
576
  * The exact bytes an operator agreed to.
577
577
  *
@@ -1359,7 +1359,7 @@ function describeApproval(approval) {
1359
1359
  export { personProof } from "./person-proof.js";
1360
1360
  import { personProof } from "./person-proof.js";
1361
1361
  import { releaseTermsDigest } from "./contracts/release-terms.js";
1362
- import { CHECK_ACCESS_WIDTH, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseSealProblem } from "./release-authority.js";
1362
+ import { CHECK_ACCESS_WIDTH, autoEvidenceOf, carriesUnsandboxed, newestReleaseGeneration, releaseAuthorityOfTask, releaseAutoApproves, releaseSealProblem } from "./release-authority.js";
1363
1363
  /** Whether the command running now is a machine's: the lead (its token) or a remote API run. Trusted context only. */
1364
1364
  export const machineActing = () => currentActor()?.lead === true || activeRemote() !== null;
1365
1365
  /** A proof row's detail, either form: the legacy text (`<at> · generation <n> · digest <d>`) or the versioned record
@@ -1400,6 +1400,8 @@ export function personApprovalOf(store, taskId) {
1400
1400
  const basis = scope.approvalBasis ?? null;
1401
1401
  if (basis === "lead")
1402
1402
  return { ok: false, why: "approved by the lead, not by a person" };
1403
+ if (basis === "auto-check" || basis === "auto-green")
1404
+ return { ok: false, why: "approved automatically, not by a person" };
1403
1405
  if (basis !== "password" && basis !== "chat")
1404
1406
  return { ok: false, why: `approved by ${basis === null ? "an unknown road" : basis === "grant" ? "a flow" : basis}, not by a person` };
1405
1407
  const origin = sealOriginOf(store, taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
@@ -1474,7 +1476,8 @@ export function liveReleaseOf(store, taskId, action, now = new Date()) {
1474
1476
  if (a.tipTask !== taskId)
1475
1477
  return refuse("this is not the release's current commit");
1476
1478
  const anchor = store.getScope(a.anchorTask);
1477
- const person = personApprovalOf(store, a.anchorTask);
1479
+ // A person's yes, or the plane's own automatic one while the project still approves releases automatically.
1480
+ const person = releaseApprovalOf(store, a.anchorTask, now);
1478
1481
  if (!person.ok)
1479
1482
  return refuse(`the release approval no longer stands: ${person.why}`);
1480
1483
  if (anchor === null || anchor.approvedDigest !== a.anchorDigest || anchor.digest !== a.anchorDigest)
@@ -1539,3 +1542,41 @@ export function mainDebtProblem(store, repo, taskId) {
1539
1542
  export function deployGateScope(scope) {
1540
1543
  return (scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null;
1541
1544
  }
1545
+ /**
1546
+ * The plane's own automatic approval of a release task (docs/plans/auto-approve-green.md), read the way the dispatch
1547
+ * belts read it (release-authority.ts autoSealSql): sealed `kind` on the current digest, its evidence row naming exactly
1548
+ * this task, kind, digest and candidate, a release task (candidate and terms signed in), the project's setting on now,
1549
+ * and the person who turned it on still an approver there. Never a person's approval.
1550
+ */
1551
+ export function autoReleaseApprovalOf(store, taskId, kind) {
1552
+ const scope = store.getScope(taskId);
1553
+ const ref = store.lookupRef(taskId);
1554
+ if (scope === null || scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
1555
+ return { ok: false, why: "not approved yet" };
1556
+ if (scope.approvedDigest !== scope.digest)
1557
+ return { ok: false, why: "the approval is for different scope text" };
1558
+ if (scope.approvalBasis !== kind)
1559
+ return { ok: false, why: kind === "auto-green" ? "not approved automatically" : "not checking automatically" };
1560
+ if ((scope.candidate ?? null) === null || (scope.releaseTerms ?? null) === null || ref === null || ref.repo === null)
1561
+ return { ok: false, why: "only a release task is approved automatically" };
1562
+ const evidence = autoEvidenceOf(store, taskId, scope.modeDigest, kind);
1563
+ if (evidence === null || evidence.digest !== scope.approvedDigest || evidence.candidate !== scope.candidate || evidence.by !== scope.approvedBy)
1564
+ return { ok: false, why: "no record shows what approved it automatically" };
1565
+ if (!releaseAutoApproves(store, ref.repo))
1566
+ return { ok: false, why: "approving releases automatically was turned off" };
1567
+ const account = store.accountOf(scope.approvedBy);
1568
+ if (account === null || account.revokedAt !== null || account.role !== "approver" || !store.accountCanAccess(scope.approvedBy, ref.repo))
1569
+ return { ok: false, why: `${scope.approvedBy}, who turned on automatic release approval, can no longer approve in this project` };
1570
+ return { ok: true, by: scope.approvedBy, at: scope.approvedAt, evidence };
1571
+ }
1572
+ /** A release's approval: a person's (personApprovalOf, unchanged), or the plane's own `auto-green`. Only release
1573
+ * readers ask this (liveReleaseOf, and the deploy gates inside `toolroll release`); everything else asks for a person. */
1574
+ export function releaseApprovalOf(store, taskId, _now = new Date()) {
1575
+ const person = personApprovalOf(store, taskId);
1576
+ if (person.ok)
1577
+ return person;
1578
+ if (store.getScope(taskId)?.approvalBasis !== "auto-green")
1579
+ return person;
1580
+ const auto = autoReleaseApprovalOf(store, taskId, "auto-green");
1581
+ return auto.ok ? { ok: true, by: auto.by, at: auto.at, method: "auto-green" } : auto;
1582
+ }
package/dist/scout.js CHANGED
@@ -18,6 +18,7 @@ import { mkdtempSync, realpathSync, rmSync, unlinkSync } from "node:fs";
18
18
  import { homedir, tmpdir } from "node:os";
19
19
  import { dirname, join, relative, isAbsolute } from "node:path";
20
20
  import { run } from "./exec.js";
21
+ import { checkoutGit } from "./teardown-git.js";
21
22
  import { auditOf } from "./provider.js";
22
23
  import { currentClaim, heartbeat } from "./claim.js";
23
24
  import { heartbeat as runnerHeartbeat } from "./runner.js";
@@ -225,7 +226,10 @@ export async function scout(store, request) {
225
226
  }
226
227
  }
227
228
  async function scoutWith(store, request, outputDir) {
228
- const { taskId, taskRef, runner, worktree, branch, now, agent, git = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
229
+ const { taskId, taskRef, runner, worktree, branch, now, agent, git: givenGit = run, timeoutMs = DEFAULT_SCOUT_TIMEOUT_MS, maxTurns = DEFAULT_SCOUT_TURNS, } = request;
230
+ // The plane's git on the scout's checkout (agent-written, and its repository shared with every build) runs hardened, against
231
+ // a frozen view of the repository's config and info (teardown-git.ts checkoutGit); an injected runner is used as given.
232
+ const git = givenGit === run ? checkoutGit(run, "build", { repo: store.getWorktree(worktree)?.repo ?? null }) : givenGit;
229
233
  const claim = currentClaim(store, taskRef, now);
230
234
  if (claim === null || claim.runner !== runner || claim.leaseId !== request.leaseId) {
231
235
  return { ok: false, kind: "failure", reason: "not-yours", message: `${taskId} is not held under ${request.leaseId}` };
package/dist/serve.js CHANGED
@@ -1,3 +1,13 @@
1
+ import { secretFolder } from "./tool-secret-store.js";
2
+ import { acceptedIdentities, recoverLearning } from "./project-learning.js";
3
+ import { loadOrCreateVapidKeys } from "./push.js";
4
+ import { checkoutPlan } from "./checkout-cleanup.js";
5
+ import { latestReleaseFor } from "./server/render-settings.js";
6
+ import { LastChecks } from "./server/last-checks.js";
7
+ import { readProviderKey } from "./keys.js";
8
+ import { closeExpiredUnattendedGrants } from "./flow-unattended.js";
9
+ import { isKeyedProvider } from "./acp-agents.js";
10
+ import { PROVIDER_IDS } from "./provider.js";
1
11
  import { taskMovedSince } from "./chat-ask.js";
2
12
  import { linuxFenceAvailable, macosFenceAvailable } from "./agent-fence.js";
3
13
  import { signInsUnavailableWords } from "./check-access.js";
@@ -12,6 +22,10 @@ const RAIL_TASKS = 6;
12
22
  const PROXY_HEADER = /^(forwarded|via|x-forwarded-.+|x-real-ip|x-client-ip|x-original-.+|x-cluster-client-ip|true-client-ip|cf-connecting-ip|fastly-client-ip|x-proxyuser-ip)$/i;
13
23
  /** Asks a project names for the chime (its count is complete beyond them). */
14
24
  const ASKS_PER_PROJECT = 100;
25
+ /** How often the background checks look at what is due (docs/plans/get-side-effects.md), and how often this
26
+ * computer's agent sign-ins are asked again. */
27
+ const BACKGROUND_CHECKS_MS = 30_000;
28
+ const LOCAL_AGENTS_EVERY_MS = 2 * 60_000;
15
29
  import { boundProjectSummary, browserCrewFromIndex, browserCrewOf, browserNavigationOf, browserProjectsOf, needsYouLabelOf } from './browser-workspace.js';
16
30
  import { sharedActionPayload } from './chat-actions.js';
17
31
  import { prepareCodingContext } from './coding-context.js';
@@ -24,6 +38,7 @@ import { configureLeadFollow, recordLeadFollowFailure, runLeadFollowPass } from
24
38
  import { leadNameOf } from "./lead-identity.js";
25
39
  import { createLiveBus, followWorkspace } from "./live-bus.js";
26
40
  import { startMaintenance } from './maintenance.js';
41
+ import { asReadOnlyRequest } from './read-only-request.js';
27
42
  import { connectionsOf, localConnectOf, oneClickOf } from "./mcp-connect.js";
28
43
  import { MOBILE_VIEWPORT_SCRIPT } from "./mobile-viewport.js";
29
44
  import { projectAuthority } from "./project-access.js";
@@ -99,7 +114,7 @@ import { hasForbiddenControls } from "./decision.js";
99
114
  import { UPDATE_PAUSED, updateAdmissionPaused } from "./desktop-update-gate.js";
100
115
  import { scanForSecrets } from "./evidence.js";
101
116
  import { run as execRun } from "./exec.js";
102
- import { firstRunSteps, signInCommandFor } from "./first-run.js";
117
+ import { findFirstTasks, firstRunSteps, signInCommandFor } from "./first-run.js";
103
118
  import { installMethod } from "./install-method.js";
104
119
  import { createIntegrationMonitor } from "./integrations.js";
105
120
  import { logEvent } from "./log.js";
@@ -245,8 +260,26 @@ export function createDecisionServer(options) {
245
260
  const providerHome = options.connectionHome ?? homedir();
246
261
  const checkConnection = createConnectionChecker({ home: providerHome, clock, ...(options.connectionProbe === undefined ? {} : { probe: options.connectionProbe }) });
247
262
  // Every sign-in check the console makes also answers a sign-in pause.
263
+ // Whether this computer can fence agents: asked once as the console starts (a sandbox running /usr/bin/true), so
264
+ // Settings → Project never starts a process (docs/plans/get-side-effects.md).
265
+ const fenceHere = options.fenceAvailable === undefined ? macosFenceAvailable() || linuxFenceAvailable() : false;
266
+ // Phone notifications' signing pair, made once as the console starts so /push/key only reads it.
267
+ if (options.telegramTokenFile !== undefined) {
268
+ try {
269
+ loadOrCreateVapidKeys(dirname(options.telegramTokenFile));
270
+ }
271
+ catch { /* /push/key says push is not set up */ }
272
+ }
273
+ // The last answer of every other check a page shows (docs/plans/get-side-effects.md).
274
+ const lastChecks = new LastChecks();
275
+ /** The newest release, asked of npm (Settings → Updates shows the last answer). */
276
+ const checkRelease = () => lastChecks.run("release", () => latestReleaseFor(options.updates?.latest));
277
+ // What each sign-in check said last: what a page shows (docs/plans/get-side-effects.md). A page never checks.
278
+ const knownConnections = new Map();
279
+ const connectionKnown = (provider) => knownConnections.get(provider) ?? null;
248
280
  const connectionCheck = async (provider, fresh) => {
249
281
  const value = await checkConnection(provider, fresh);
282
+ knownConnections.set(provider, value);
250
283
  try {
251
284
  noteSignInProbe(store, provider, value.state, clock());
252
285
  }
@@ -382,6 +415,17 @@ export function createDecisionServer(options) {
382
415
  ssoProvider = { issuer, provider: found.provider, at: Date.now() };
383
416
  return found;
384
417
  }
418
+ /** The identity provider as last discovered: what starting a sign-in uses (a GET, so it fetches nothing,
419
+ * docs/plans/get-side-effects.md). Discovered as the console starts, by the background checks every ten minutes, and
420
+ * when sign-in settings are saved or tested. */
421
+ function providerKnown(issuer) {
422
+ return ssoProvider !== null && ssoProvider.issuer === issuer ? ssoProvider.provider : null;
423
+ }
424
+ {
425
+ const settings = ssoSettings();
426
+ if (settings !== null)
427
+ void providerFor(settings.issuer).catch(() => undefined);
428
+ }
385
429
  /** Password sign-in is only for instance operators when the provider says so (a way in if it's down). */
386
430
  const passwordAllowed = (name) => { const settings = ssoSettings(); return settings === null || settings.passwords === "everyone" || store.isInstanceOperator(name); };
387
431
  const minutesWords = (ms) => { const minutes = Math.max(1, Math.ceil(ms / 60_000)); return minutes >= 120 ? `${Math.round(minutes / 60)} hours` : `${minutes} minute${minutes === 1 ? "" : "s"}`; };
@@ -456,7 +500,7 @@ export function createDecisionServer(options) {
456
500
  setting: store.projectCheckAccess(repo), changed: store.projectCheckAccessChange(repo),
457
501
  canNarrow: who.via === "cookie" && who.role === "approver" && !store.isDemo() && store.accountCanAccess(who.name, repo),
458
502
  canWiden: who.via === "cookie" && !store.isDemo() && store.isInstanceOperator(who.name),
459
- unconfinedHere: !(options.fenceAvailable?.() ?? (macosFenceAvailable() || linuxFenceAvailable())) && store.unfencedAgentsAllowed(),
503
+ unconfinedHere: !(options.fenceAvailable?.() ?? fenceHere) && store.unfencedAgentsAllowed(),
460
504
  signInsUnavailable: signInsUnavailableWords(options.platform ?? process.platform),
461
505
  },
462
506
  liveView: (() => {
@@ -613,7 +657,9 @@ export function createDecisionServer(options) {
613
657
  const server = createServer((request, response) => {
614
658
  instrumentRequest(store.telemetry, request, response);
615
659
  // Every password checked while answering counts against where this request came from (sign-in-guard.ts).
616
- void withPasswordSource(joinSourceOf(request), () => handle(request, response)).catch(error => {
660
+ // A GET or HEAD only reads (docs/plans/get-side-effects.md): store reads that would tidy up as they go don't.
661
+ const answer = () => handle(request, response);
662
+ void withPasswordSource(joinSourceOf(request), () => request.method === "GET" || request.method === "HEAD" ? asReadOnlyRequest(answer) : answer()).catch(error => {
617
663
  // Every unhandled error is logged (v99): the path and the message, never the request's body or query.
618
664
  logEvent("error", "serve.error", { method: request.method, path: redactedPath(new URL(request.url ?? "/", "http://placeholder").pathname), error: error instanceof Error ? error.message : String(error) });
619
665
  if (envValue(process.env, "SERVE_DEBUG") === "1")
@@ -1073,9 +1119,9 @@ export function createDecisionServer(options) {
1073
1119
  .finally(() => { localSignInChecking = null; });
1074
1120
  return localSignInChecking;
1075
1121
  }
1122
+ /** The last answer only (docs/plans/get-side-effects.md): this computer is asked again by the background checks or a
1123
+ * person's Check now, never because a page was opened. */
1076
1124
  function localAgentSignedIn() {
1077
- if (localSignInChecking === null && (localSignIn === null || Date.now() - localSignIn.at > 30_000))
1078
- void checkLocalAgents();
1079
1125
  return localSignIn?.signedIn ?? null;
1080
1126
  }
1081
1127
  /**
@@ -1305,6 +1351,11 @@ export function createDecisionServer(options) {
1305
1351
  * whole catalog, not a hand-pinned shortlist). null = no key or the
1306
1352
  * catalog is unreachable; callers fall back to the compiled table. */
1307
1353
  let catalogCache = null;
1354
+ /** The catalog as last fetched, for a page to show (docs/plans/get-side-effects.md): a page never fetches it. The
1355
+ * background checks keep it current while an OpenRouter key is saved; saving a choice fetches it (chatCatalog). */
1356
+ function chatCatalogKnown() {
1357
+ return chatKeyFor("openrouter-api") === null ? null : catalogCache?.models ?? null;
1358
+ }
1308
1359
  async function chatCatalog() {
1309
1360
  const key = chatKeyFor("openrouter-api")?.key;
1310
1361
  if (key === undefined)
@@ -1820,6 +1871,106 @@ export function createDecisionServer(options) {
1820
1871
  onError: error => recordLeadFollowFailure(store, error, clock()),
1821
1872
  });
1822
1873
  });
1874
+ // Background checks (docs/plans/get-side-effects.md): what pages show about this computer's agent sign-ins and the
1875
+ // services Toolroll uses is asked here, on a timer, and a page reads the last answer. A page visit never starts a
1876
+ // process or a network call, and never acts on what a check finds: lifting a sign-in pause and the lead-by-default
1877
+ // rule run from this pass (or a person's Check now), not from a GET.
1878
+ let backgroundChecks = null;
1879
+ let agentsCheckedAt = 0;
1880
+ if (options.backgroundChecks === true)
1881
+ server.once('listening', () => {
1882
+ // This Toolroll's folder for secrets, checked once now, so a page can read which are set without preparing it.
1883
+ try {
1884
+ secretFolder();
1885
+ }
1886
+ catch { /* a page says secrets can't be read; the next action reports why */ }
1887
+ backgroundChecks = startMaintenance({ intervalMs: options.backgroundChecksMs ?? BACKGROUND_CHECKS_MS, shouldStop: () => leadClosing,
1888
+ run: async () => {
1889
+ const now = Date.now();
1890
+ const waits = [];
1891
+ if (now - agentsCheckedAt >= (options.backgroundChecksMs ?? LOCAL_AGENTS_EVERY_MS)) {
1892
+ agentsCheckedAt = now;
1893
+ waits.push(checkLocalAgents());
1894
+ // The other agents Settings lists: their last answer is what the page shows.
1895
+ for (const provider of PROVIDER_IDS.filter(isKeyedProvider).filter(one => one !== "claude" && one !== "codex"))
1896
+ waits.push(connectionCheck(provider).catch(() => undefined));
1897
+ }
1898
+ integrations.refreshIfDue();
1899
+ const sso = ssoSettings();
1900
+ if (sso !== null && (ssoProvider === null || ssoProvider.issuer !== sso.issuer || Date.now() - ssoProvider.at >= 10 * 60_000))
1901
+ waits.push(providerFor(sso.issuer));
1902
+ if (chatKeyFor("openrouter-api") !== null)
1903
+ waits.push(chatCatalog());
1904
+ const openrouter = readProviderKey("openrouter", providerHome);
1905
+ // The cache answers until its list is due again (ten minutes, or 30 s after a failure), then fetches.
1906
+ if (openrouter !== null)
1907
+ waits.push(modelCatalog(openrouter));
1908
+ // What used to be closed by whichever page read it first: an operating mode or a flow's "runs on its own"
1909
+ // grant past its expiry, a stalled update, old coordinator proposals. (Lead turns past their deadline are the
1910
+ // follow pass's, which settles them even while an update drains.)
1911
+ const at = clock();
1912
+ store.closeExpiredModes(at);
1913
+ closeExpiredUnattendedGrants(store, at);
1914
+ store.sweepCoordinatorProposals(at);
1915
+ // The demo's scripted story, if a beat timer missed (its pages only read it).
1916
+ try {
1917
+ options.demoLead?.advance(at);
1918
+ }
1919
+ catch { /* the next pass tries again */ }
1920
+ const databaseFile = store.databaseFile();
1921
+ if (databaseFile !== null && !updateChecksOff(process.env, options.configDir ?? dirname(databaseFile)).off) {
1922
+ const last = lastChecks.known("release");
1923
+ if (lastChecks.due("release", last !== null && "problem" in last.value ? 60_000 : 15 * 60_000))
1924
+ waits.push(checkRelease());
1925
+ }
1926
+ // Chat's first tasks (issues and TODOs) while the first run is open and nothing is filed yet.
1927
+ if (firstRunStepsNow(clock()) !== null && !store.hasAnyWork()) {
1928
+ for (const repo of managedRepos()) {
1929
+ const entry = firstTasks.get(repo);
1930
+ if (entry !== undefined && (entry.reading || entry.until > Date.now()))
1931
+ continue;
1932
+ const reading = { until: Date.now() + 10 * 60_000, found: entry?.found ?? null, reading: true };
1933
+ firstTasks.set(repo, reading);
1934
+ waits.push(findFirstTasks(repo, options.firstTaskRunner ?? execRun).then(found => { reading.found = found; }, () => { }).finally(() => { reading.reading = false; }));
1935
+ }
1936
+ }
1937
+ // Learning recovered from reviewed runs (and a project's saved rows carried to its current identity), which
1938
+ // Settings → Learning and Knowledge used to do as they opened.
1939
+ if (lastChecks.due("learning", 10 * 60_000)) {
1940
+ waits.push(lastChecks.run("learning", async () => {
1941
+ for (const repo of managedRepos()) {
1942
+ try {
1943
+ acceptedIdentities(store, repo);
1944
+ }
1945
+ catch { /* the next pass tries again */ }
1946
+ try {
1947
+ recoverLearning(store, evidenceRoot, repo);
1948
+ }
1949
+ catch { /* the next pass tries again */ }
1950
+ }
1951
+ }));
1952
+ }
1953
+ // What Codex has configured, per project, for Settings → Tools to offer.
1954
+ for (const repo of managedRepos()) {
1955
+ if (lastChecks.due(`codex-tools:${repo}`, 10 * 60_000))
1956
+ waits.push(lastChecks.run(`codex-tools:${repo}`, () => codexServers(repo)));
1957
+ }
1958
+ if (databaseFile !== null && lastChecks.due("storage", 30 * 60_000)) {
1959
+ waits.push(lastChecks.run("storage", () => checkoutPlan(store, storagePool(databaseFile), clock(), { manual: true })));
1960
+ }
1961
+ // The same reading the chrome does, outside a page visit, so it releases a stalled `toolroll update` and a stalled
1962
+ // desktop update in every folder the chrome looks in.
1963
+ if (databaseFile !== null) {
1964
+ try {
1965
+ waitingUpdate(databaseFile, run => store.stopQuiescenceProblem(run) !== null, at);
1966
+ }
1967
+ catch { /* shown as last saved */ }
1968
+ }
1969
+ await Promise.all(waits);
1970
+ },
1971
+ onError: () => { }, });
1972
+ void backgroundChecks.runNow();
1973
+ });
1823
1974
  // Keep native process custody until shutdown has verified agent/tool exit.
1824
1975
  // Coding custody does not depend on subagents or the lead's follow pass, so
1825
1976
  // it closes at once and never waits behind a model turn: a stop must release
@@ -1848,7 +1999,7 @@ export function createDecisionServer(options) {
1848
1999
  liveHandlers.close();
1849
2000
  detachReads();
1850
2001
  liveFollower.close();
1851
- void Promise.all([closeCoding(), terminalClosing.close?.().then(problem => { terminalClosing.problem = problem; }), bounded(team.close()), bounded(personalQueue.close()), bounded(leadMaintenance?.stop()), bounded(reads?.close())])
2002
+ void Promise.all([closeCoding(), terminalClosing.close?.().then(problem => { terminalClosing.problem = problem; }), bounded(team.close()), bounded(personalQueue.close()), bounded(leadMaintenance?.stop()), bounded(backgroundChecks?.stop()), bounded(reads?.close())])
1852
2003
  // The server always closes; a terminal whose processes couldn't be proved stopped is the close's error.
1853
2004
  .then(() => closeServer(error => callback?.(error ?? terminalClosing.problem ?? undefined))).catch(error => {
1854
2005
  if (callback)
@@ -1924,6 +2075,7 @@ export function createDecisionServer(options) {
1924
2075
  get liveRefreshSeconds() { return liveRefreshSeconds; },
1925
2076
  get chatKeyFor() { return chatKeyFor; },
1926
2077
  get chatCatalog() { return chatCatalog; },
2078
+ get chatCatalogKnown() { return chatCatalogKnown; },
1927
2079
  get leadRecheckedAt() { return leadRecheckedAt; },
1928
2080
  set leadRecheckedAt(value) { leadRecheckedAt = value; },
1929
2081
  get checkLocalAgents() { return checkLocalAgents; },
@@ -1931,6 +2083,9 @@ export function createDecisionServer(options) {
1931
2083
  set localSignIn(value) { localSignIn = value; },
1932
2084
  get agentSignInCommand() { return agentSignInCommand; },
1933
2085
  get connectionCheck() { return connectionCheck; },
2086
+ get connectionKnown() { return connectionKnown; },
2087
+ get lastChecks() { return lastChecks; },
2088
+ get checkRelease() { return checkRelease; },
1934
2089
  get modelCatalog() { return modelCatalog; },
1935
2090
  get modelSeams() { return modelSeams; },
1936
2091
  get toolsViewOf() { return toolsViewOf; },
@@ -1960,6 +2115,7 @@ export function createDecisionServer(options) {
1960
2115
  get ssoProvider() { return ssoProvider; },
1961
2116
  set ssoProvider(value) { ssoProvider = value; },
1962
2117
  get providerFor() { return providerFor; },
2118
+ get providerKnown() { return providerKnown; },
1963
2119
  get connectVisits() { return connectVisits; },
1964
2120
  get defaultProject() { return defaultProject; },
1965
2121
  get googleVisits() { return googleVisits; },
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Work a lead filed, nested under it on the Live grid (docs/plans/lead-dispatched-tiles.md): which of the viewer's
3
+ * tiles were filed through the viewer's OWN lead chat, and which chat. Proved from the store only, at request time: the
4
+ * task's family root was filed through the lead (`filed_via = 'mate'`) by the viewer as a person, and exactly one record
5
+ * links that root to one of the viewer's own personal lead threads — a confirmed task card the viewer confirmed (its
6
+ * door-written outcome, never the model's payload) or a sweep the viewer started from a card still kept. Attribution is
7
+ * to the chat that filed the family's ORIGINAL task: a revision asked for in another chat stays with the family. Anything
8
+ * short of that is no link:
9
+ * the tile stays a top-level tile, as it is today. A link only groups tiles the viewer already gets; it never adds one.
10
+ */
11
+ import type { Store } from "../store.js";
12
+ export type FiledBy = {
13
+ thread: number;
14
+ chat: "lead" | "project" | "task";
15
+ };
16
+ /** For each run id given (the tiles already in this viewer's answer), the viewer's lead chat that filed its task. */
17
+ export declare function leadFiledLinks(store: Store, viewer: string, runIds: readonly number[]): Map<number, FiledBy>;
@@ -0,0 +1,97 @@
1
+ const BUILT_IN = "built-in";
2
+ /** The family root of a built-in task, or null when its lineage can't be proved: the family's own proof (taskFamilyOf:
3
+ * every revision in the root's project, each bound by its brief to its parent's own run), and the task one of it. */
4
+ function rootOf(store, taskId) {
5
+ const family = store.taskFamilyOf(taskId, null, false);
6
+ return family === null || family.problem !== null || !family.versions.some(version => version.id === taskId) ? null : family.root.id;
7
+ }
8
+ const parse = (text) => {
9
+ if (typeof text !== "string")
10
+ return null;
11
+ try {
12
+ const value = JSON.parse(text);
13
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ /** For each run id given (the tiles already in this viewer's answer), the viewer's lead chat that filed its task. */
20
+ export function leadFiledLinks(store, viewer, runIds) {
21
+ const links = new Map();
22
+ const db = store.handle;
23
+ // The viewer's own personal threads (never a team conversation's), by id.
24
+ const threads = new Map();
25
+ for (const row of db.prepare(`SELECT id, scope_kind FROM lead_thread WHERE approver = ? AND NOT EXISTS (SELECT 1 FROM team_conversation tc WHERE tc.thread = lead_thread.id)`).all(viewer)) {
26
+ const kind = row["scope_kind"];
27
+ threads.set(Number(row["id"]), kind === "project" ? "project" : kind === "task" ? "task" : "lead");
28
+ }
29
+ if (threads.size === 0)
30
+ return links;
31
+ // Each run's family root, proved once per task (attempts of one task share it), and only roots the viewer filed
32
+ // through the lead as a person go further.
33
+ const rootOfTask = new Map();
34
+ const runRoots = new Map();
35
+ const repos = new Map();
36
+ const refRow = db.prepare("SELECT repo, filed_via, filed_by, filed_by_kind FROM task_ref WHERE backend = ? AND external_id = ?");
37
+ for (const runId of runIds) {
38
+ const row = db.prepare("SELECT r.backend, r.external_id FROM run JOIN task_ref r ON r.id = run.task_ref WHERE run.id = ?").get(runId);
39
+ if (row === undefined || row["backend"] !== BUILT_IN)
40
+ continue;
41
+ const task = String(row["external_id"]);
42
+ if (!rootOfTask.has(task))
43
+ rootOfTask.set(task, rootOf(store, task));
44
+ const root = rootOfTask.get(task);
45
+ if (root === null || root === undefined)
46
+ continue;
47
+ if (!repos.has(root)) {
48
+ const ref = refRow.get(BUILT_IN, root);
49
+ // Filed through the lead, by the viewer, as a person.
50
+ const filed = ref !== undefined && ref["filed_via"] === "mate" && ref["filed_by_kind"] === "person" && ref["filed_by"] === viewer && typeof ref["repo"] === "string";
51
+ repos.set(root, filed ? String(ref["repo"]) : "");
52
+ }
53
+ if (repos.get(root) !== "")
54
+ runRoots.set(runId, root);
55
+ }
56
+ const roots = [...new Set(runRoots.values())];
57
+ if (roots.length === 0)
58
+ return links;
59
+ // Every confirmed task card in the viewer's own threads naming one of these roots: ONE read per request, through the
60
+ // viewer's threads (lead_thread by approver, lead_proposal by thread and state), never once per family.
61
+ const found = new Map(roots.map(root => [root, []]));
62
+ const spoiled = new Set();
63
+ const cards = db.prepare(`SELECT p.thread, p.resolved_by, p.payload_json, p.outcome_json, json_extract(p.outcome_json, '$.taskId') AS task FROM lead_proposal p
64
+ JOIN lead_thread t ON t.id = p.thread WHERE t.approver = ? AND p.kind = 'task' AND p.state = 'confirmed' AND json_valid(p.outcome_json)
65
+ AND json_extract(p.outcome_json, '$.taskId') IN (${roots.map(() => "?").join(",")})`).all(viewer, ...roots);
66
+ for (const row of cards) {
67
+ const root = String(row["task"]);
68
+ const outcome = parse(row["outcome_json"]), payload = parse(row["payload_json"]);
69
+ // The door's own outcome (the model writes only the payload), confirmed by the viewer, for this project.
70
+ if (outcome?.["ok"] !== true || outcome["kind"] !== "task" || outcome["taskId"] !== root || row["resolved_by"] !== viewer || payload?.["repo"] !== repos.get(root))
71
+ spoiled.add(root);
72
+ else
73
+ found.get(root)?.push(Number(row["thread"]));
74
+ }
75
+ // A sweep links while the card that started it is kept, as a task card does: ending a chat (or revocation) erases its
76
+ // cards, and with them every link it made — one retention rule for both.
77
+ const sweeps = db.prepare(`SELECT k.root_task, s.owner, s.thread, s.repo, p.thread AS card_thread, p.state AS card_state, p.resolved_by AS card_by
78
+ FROM lead_sweep_task k JOIN lead_sweep s ON s.id = k.sweep LEFT JOIN lead_proposal p ON p.id = s.proposal
79
+ WHERE k.root_task IN (${roots.map(() => "?").join(",")})`).all(...roots);
80
+ for (const row of sweeps) {
81
+ const root = String(row["root_task"]);
82
+ if (row["owner"] !== viewer || row["repo"] !== repos.get(root) || row["thread"] === null || row["card_thread"] !== row["thread"] || row["card_state"] !== "confirmed" || row["card_by"] !== viewer)
83
+ spoiled.add(root);
84
+ else
85
+ found.get(root)?.push(Number(row["thread"]));
86
+ }
87
+ for (const [runId, root] of runRoots) {
88
+ // Exactly one record, nothing amiss beside it, naming one of the viewer's own personal threads.
89
+ const named = found.get(root) ?? [];
90
+ if (spoiled.has(root) || named.length !== 1)
91
+ continue;
92
+ const chat = threads.get(named[0]);
93
+ if (chat !== undefined)
94
+ links.set(runId, { thread: named[0], chat });
95
+ }
96
+ return links;
97
+ }
@@ -11,6 +11,7 @@ import { closeSync } from "node:fs";
11
11
  import { agentsLiveOf } from "../agents-live.js";
12
12
  import { createLiveRooms } from "../live-bus.js";
13
13
  import { openLiveTrusted } from "../live-open.js";
14
+ import { leadFiledLinks } from "./agents-live-filed.js";
14
15
  import { requestContext } from "./request-context.js";
15
16
  /** How often a viewer's runs and files are looked at again. */
16
17
  export const LIVE_POLL_MS = 2_000;
@@ -79,7 +80,9 @@ export function createAgentsLiveRooms(input) {
79
80
  return repo !== null && visible(repo) && store.accountCanAccess(account, repo);
80
81
  };
81
82
  const kept = new Set(tiles.filter(tile => still(tile.runId)).map(tile => tile.runId));
82
- const seen = tiles.filter(tile => kept.has(tile.runId)).map(tile => [tile.runId, tile.state, tile.why, tile.endedAt]);
83
+ // Which of the viewer's own lead chats filed each run (agents-live-filed.ts): a link made or erased is a change too.
84
+ const filed = leadFiledLinks(store, account, [...kept]);
85
+ const seen = tiles.filter(tile => kept.has(tile.runId)).map(tile => [tile.runId, tile.state, tile.why, tile.endedAt, filed.get(tile.runId)?.thread ?? null]);
83
86
  const shownMarks = marks.filter(([runId]) => kept.has(runId) && (!tiles.find(tile => tile.runId === runId)?.local || sees(runId)()));
84
87
  return createHash("sha256").update(JSON.stringify([seen, shownMarks, input.leads?.(key) ?? null])).digest("base64url");
85
88
  });