toolroll 0.9.74 → 0.9.75

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 (83) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +27 -8
  4. package/dist/browser/workspace.js +27 -27
  5. package/dist/browser-workspace.d.ts +1 -1
  6. package/dist/chat-acceptance.d.ts +4 -1
  7. package/dist/chat-acceptance.js +13 -3
  8. package/dist/cli.js +1 -1
  9. package/dist/coding-handoff.js +4 -3
  10. package/dist/coding-ui.js +2 -2
  11. package/dist/coding-workspace.js +16 -4
  12. package/dist/contracts/console-api.d.ts +5 -1
  13. package/dist/contracts/console-api.js +10 -0
  14. package/dist/control-ui.d.ts +3 -2
  15. package/dist/control-ui.js +7 -2
  16. package/dist/demo.js +5 -3
  17. package/dist/desktop-update.d.ts +3 -0
  18. package/dist/desktop-update.js +43 -9
  19. package/dist/first-run.d.ts +8 -5
  20. package/dist/first-run.js +39 -6
  21. package/dist/flow-card-task.js +3 -2
  22. package/dist/flow-unattended.d.ts +2 -0
  23. package/dist/flow-unattended.js +12 -1
  24. package/dist/integrations.d.ts +4 -3
  25. package/dist/integrations.js +5 -6
  26. package/dist/lead-contract.d.ts +1 -1
  27. package/dist/lead-contract.js +2 -2
  28. package/dist/lead-follow.js +3 -1
  29. package/dist/openrouter-models.d.ts +5 -2
  30. package/dist/openrouter-models.js +7 -4
  31. package/dist/operate.js +2 -0
  32. package/dist/project-learning.js +44 -6
  33. package/dist/project-tools.js +19 -1
  34. package/dist/pull-request-ui.d.ts +1 -1
  35. package/dist/pull-request-ui.js +5 -1
  36. package/dist/push.d.ts +2 -0
  37. package/dist/push.js +24 -19
  38. package/dist/read-only-request.d.ts +4 -0
  39. package/dist/read-only-request.js +15 -0
  40. package/dist/repository-context.js +6 -1
  41. package/dist/result-follow-ups.js +20 -1
  42. package/dist/result-review.js +5 -1
  43. package/dist/serve.js +162 -6
  44. package/dist/server/agents-live-filed.d.ts +17 -0
  45. package/dist/server/agents-live-filed.js +97 -0
  46. package/dist/server/agents-live-room.js +4 -1
  47. package/dist/server/chat.js +31 -23
  48. package/dist/server/http.d.ts +5 -0
  49. package/dist/server/last-checks.d.ts +17 -0
  50. package/dist/server/last-checks.js +22 -0
  51. package/dist/server/pages.js +18 -4
  52. package/dist/server/people-tokens.js +7 -4
  53. package/dist/server/render-pages.d.ts +2 -1
  54. package/dist/server/render-pages.js +28 -24
  55. package/dist/server/render-results.d.ts +11 -2
  56. package/dist/server/render-results.js +54 -12
  57. package/dist/server/render-settings.js +3 -3
  58. package/dist/server/render-tasks.js +7 -4
  59. package/dist/server/route-table.js +14 -1
  60. package/dist/server/runtime.d.ts +16 -1
  61. package/dist/server/settings.js +82 -29
  62. package/dist/server/task-views.js +0 -1
  63. package/dist/server/tasks.js +11 -2
  64. package/dist/storage-ui.d.ts +2 -0
  65. package/dist/storage-ui.js +1 -1
  66. package/dist/storage.js +2 -1
  67. package/dist/store.d.ts +3 -0
  68. package/dist/store.js +33 -9
  69. package/dist/subagent-work.js +3 -0
  70. package/dist/task-status.d.ts +28 -1
  71. package/dist/task-status.js +93 -11
  72. package/dist/teardown-git.d.ts +27 -1
  73. package/dist/teardown-git.js +185 -39
  74. package/dist/telegram-progress.js +4 -4
  75. package/dist/telegram-status.js +8 -3
  76. package/dist/tool-secret-store.js +8 -0
  77. package/dist/toolroll-update-ui.d.ts +2 -0
  78. package/dist/toolroll-update-ui.js +4 -3
  79. package/dist/toolroll-update.js +10 -9
  80. package/dist/work-index.js +29 -12
  81. package/dist/workspace-ui.d.ts +5 -0
  82. package/dist/workspace-ui.js +38 -10
  83. package/package.json +1 -1
@@ -276,7 +276,7 @@ export type BrowserSettingsView = {
276
276
  connection: {
277
277
  words: string;
278
278
  facts: string;
279
- checkHref: string;
279
+ checkAction: string;
280
280
  } | null;
281
281
  usage: string;
282
282
  envName: string;
@@ -4,12 +4,15 @@
4
4
  import type { Store } from "./store.js";
5
5
  import type { VerifiedApprover } from "./principal.js";
6
6
  import { type CriterionMatrixRow } from "./proof.js";
7
+ import { type ApprovedCheckState } from "./task-status.js";
7
8
  export declare const ACCEPTANCE_PAGE_SIZE = 3;
9
+ /** `check`: the approved check read now (task-status.ts approvedCheckOf); undefined when this read had no saved files,
10
+ * and then a verified result claims nothing about its check. */
8
11
  export declare function acceptanceStatus(proof: {
9
12
  verdict: string;
10
13
  reasons: readonly string[];
11
14
  matrix: readonly CriterionMatrixRow[];
12
- } | null, accepted: boolean): string;
15
+ } | null, accepted: boolean, check?: ApprovedCheckState | null): string;
13
16
  export declare function readAcceptanceEvidence(store: Store, who: VerifiedApprover, root: string | undefined, task: string, runId?: number, offset?: number): {
14
17
  ok: false;
15
18
  message: string;
@@ -1,12 +1,19 @@
1
1
  import { readVerifiedArtifact, readVerifiedProofForRun, scanForSecrets } from "./evidence.js";
2
2
  import { verificationEvidence } from "./verification-evidence.js";
3
3
  import { manualReviewOnly } from "./proof.js";
4
+ import { assignmentChecksForRun } from "./assignment.js";
5
+ import { approvedCheckOf, approvedCheckStateOf, checksFactOf } from "./task-status.js";
4
6
  export const ACCEPTANCE_PAGE_SIZE = 3;
5
- export function acceptanceStatus(proof, accepted) {
7
+ /** `check`: the approved check read now (task-status.ts approvedCheckOf); undefined when this read had no saved files,
8
+ * and then a verified result claims nothing about its check. */
9
+ export function acceptanceStatus(proof, accepted, check) {
6
10
  if (accepted)
7
11
  return manualReviewOnly(proof) ? "Accepted by a person" : "Accepted with an exception";
8
12
  if (manualReviewOnly(proof))
9
13
  return "You check the remaining requirements";
14
+ if (proof?.verdict === "verified" && check !== "passed") {
15
+ return check === undefined ? "Open the result to see its checks" : check === "failed" ? "Checks failed" : check === "running" ? "Checks running" : "The check result couldn't be read";
16
+ }
10
17
  return proof === null ? "No checks recorded" : ({ verified: "Checks passed", attested: "Checks reported by the agent", short: "Some required material is missing", refuted: "Saved result conflicts with the approved scope" }[proof.verdict] ?? "Some required material is missing");
11
18
  }
12
19
  export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0) {
@@ -48,6 +55,9 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
48
55
  else if (!proof.ok)
49
56
  problems.push("The saved proof is unavailable or cannot be read.");
50
57
  const verification = root === undefined ? null : verificationEvidence(store, root, run.id);
58
+ // The one answer to "did the approved check pass" (task-status.ts), with follow-ups on the same commit.
59
+ const checkNow = root === undefined || run.role === "scout" ? undefined
60
+ : approvedCheckStateOf(approvedCheckOf(recorded?.verdict ?? null, recorded?.reasons ?? [], checksFactOf(assignmentChecksForRun(store, root, run.id, new Date()), run.headRevision)));
51
61
  let gate = { status: "No machine verification receipt available" };
52
62
  if (verification !== null && !verification.ok) {
53
63
  problems.push(verification.problem);
@@ -55,7 +65,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
55
65
  }
56
66
  else if (verification?.ok && verification.bytes !== null) {
57
67
  const receipt = JSON.parse(verification.bytes);
58
- gate = { status: receipt.result.ran ? (receipt.result.exitCode === 0 ? "Passed" : "Failed") : "Did not run", exitCode: receipt.result.exitCode ?? null, command: "The approved project verification command", logShortened: receipt.log.truncated };
68
+ gate = { status: receipt.result.ran ? (receipt.result.exitCode === 0 ? checkNow === undefined || checkNow === "passed" ? "Passed" : "Passed when it ran; not counted now" : "Failed") : "Did not run", exitCode: receipt.result.exitCode ?? null, command: "The approved project verification command", logShortened: receipt.log.truncated };
59
69
  }
60
70
  // These are summaries for conversation, not substitute approval terms.
61
71
  // Full statements, caveats and references remain on the authenticated screen.
@@ -71,7 +81,7 @@ export function readAcceptanceEvidence(store, who, root, task, runId, offset = 0
71
81
  return { ok: true, body: {
72
82
  task, run: run.id, title: store.getTask(task)?.title ?? task, currentExecution: family.current.id,
73
83
  isCurrent: family.current.id === task && store.runsFor(ref.id).find(one => one.finishedAt !== null && ["builder", "repair", "scout"].includes(one.role))?.id === run.id,
74
- status: acceptanceStatus(recorded, acceptance !== null), recordedVerdict: recorded?.verdict ?? null,
84
+ status: acceptanceStatus(recorded, acceptance !== null, checkNow), recordedVerdict: recorded?.verdict ?? null,
75
85
  accepted: acceptance !== null, acceptance: acceptance === null ? null : { at: acceptance.acceptedAt, note: brief(acceptance.note) },
76
86
  reasons: (recorded?.reasons ?? []).map(one => brief(one)), gate,
77
87
  criteriaTotal: matrix.length, nextCriterionOffset: offset + page.length < matrix.length ? offset + page.length : null,
package/dist/cli.js CHANGED
@@ -1301,7 +1301,7 @@ async function runDemoCommand(argv, write) {
1301
1301
  const now = new Date();
1302
1302
  const { sandbox, store, seed, evidenceRoot, passwordFile, lead } = createDemoSandbox(now);
1303
1303
  const server = createDecisionServer({
1304
- store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead,
1304
+ store, evidenceRoot, clock: () => new Date(), repos: seed.repos, demoLead: lead, backgroundChecks: true,
1305
1305
  ...(allowedHosts.length === 0 ? {} : { allowedHosts }),
1306
1306
  });
1307
1307
  await new Promise((ready, failed) => {
@@ -1,4 +1,5 @@
1
1
  /** A committed native session enters the existing review flow, never another executor. */
2
+ import { checkoutGitSync } from './teardown-git.js';
2
3
  import { execFileSync } from 'node:child_process';
3
4
  import { lstatSync, realpathSync } from 'node:fs';
4
5
  import { sqliteRuntime } from './sqlite-runtime.js';
@@ -52,11 +53,11 @@ function catalog(store, readOnly) {
52
53
  throw error;
53
54
  }
54
55
  }
56
+ // The coding checkout is written by an agent (its config, hooks, filters): every read goes through the hardened
57
+ // checkout git, which the code page's GET preview also uses (docs/plans/get-side-effects.md).
55
58
  function git(repo, args) {
56
59
  try {
57
- return execFileSync('git', ['--no-optional-locks', '--no-replace-objects', '--no-lazy-fetch', '-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], {
58
- encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 60_000, maxBuffer: 1_048_576,
59
- }).trimEnd();
60
+ return checkoutGitSync(repo, ['--no-replace-objects', '--no-lazy-fetch', ...args]).trimEnd();
60
61
  }
61
62
  catch {
62
63
  throw Error('The committed coding result could not be verified in Git. Check the project and try again.');
package/dist/coding-ui.js CHANGED
@@ -76,7 +76,7 @@ export function codingWorkspaceHtml(input) {
76
76
  const blocked = !s.nativeThreadId || !['ready', 'working', 'failed', 'interrupted'].includes(s.status);
77
77
  const messages = selected.items.filter(item => item.type !== 'reasoning').map(itemHtml);
78
78
  const composer = postForm(`${sessionUrl(s.id)}/send`, html `<label for="coding-prompt">Message Codex</label><textarea id="coding-prompt" name="prompt" rows="2" maxlength="24000" required placeholder="Describe a change or ask about the result…">${input.draft ?? ''}</textarea><div class="coding-actions"><button type="submit"${blocked ? html ` disabled` : ''}>${s.status === 'working' ? 'Send update' : 'Send message'}</button></div>`, { attrs: { 'data-coding-form': 'send', class: 'coding-composer', hidden: s.status === 'closed' }, hidden: { requestId: randomUUID() } });
79
- main = html `<header class="coding-header"><div><h1>${s.title}</h1><p class="coding-meta">${projectName(s.repo)} · Codex${s.model ? html ` · ${s.model}` : ''}</p></div><details class="coding-session-detail"><summary aria-label="Session details">Details</summary><dl><dt>Branch</dt><dd>${s.branch}</dd><dt>Worktree</dt><dd>${s.worktree}</dd><dt>Native session</dt><dd>${s.nativeThreadId ?? 'Not established'}</dd>${s.initialRequest ? html `<dt>Initial request</dt><dd class="coding-text">${s.initialRequest.prompt}</dd>` : ''}</dl></details></header><div class="coding-state-row"><p id="coding-state" role="status">${stateLabel(s, selected.requests)}</p><div id="coding-controls">${controlsHtml(s)}</div></div><div id="coding-requests">${selected.requests.map(r => requestHtml(r, s.id))}</div><div id="coding-conversation" class="coding-conversation" tabindex="0" aria-label="Conversation"${s.status === 'closed' && messages.length === 0 ? html ` hidden` : ''}>${messages.length ? messages : html `<p class="coding-meta coding-no-messages">No messages yet.</p>`}</div><button type="button" id="coding-latest" class="coding-secondary" hidden>Latest update ↓</button>${composer}<details id="coding-changes" class="coding-changes"><summary>Changes</summary><div id="coding-change-content"><p class="coding-meta">Open to load the current changes.</p></div><button type="button" id="coding-refresh-changes" class="coding-secondary">Refresh changes</button><noscript><a href="${sessionUrl(s.id)}/changes">View changes</a></noscript><p id="coding-shipping"${['closed', 'uncertain'].includes(s.status) ? html ` hidden` : ''}><a class="coding-link-button" href="${sessionUrl(s.id)}/ship">Review for shipping</a></p></details>`;
79
+ main = html `<header class="coding-header"><div><h1>${s.title}</h1><p class="coding-meta">${projectName(s.repo)} · Codex${s.model ? html ` · ${s.model}` : ''}</p></div><details class="coding-session-detail"><summary aria-label="Session details">Details</summary><dl><dt>Branch</dt><dd>${s.branch}</dd><dt>Worktree</dt><dd>${s.worktree}</dd><dt>Native session</dt><dd>${s.nativeThreadId ?? 'Not established'}</dd>${s.initialRequest ? html `<dt>Initial request</dt><dd class="coding-text">${s.initialRequest.prompt}</dd>` : ''}</dl></details></header><div class="coding-state-row"><p id="coding-state" role="status">${stateLabel(s, selected.requests)}</p><div id="coding-controls">${controlsHtml(s)}</div></div><div id="coding-requests">${selected.requests.map(r => requestHtml(r, s.id))}</div><div id="coding-conversation" class="coding-conversation" tabindex="0" aria-label="Conversation"${s.status === 'closed' && messages.length === 0 ? html ` hidden` : ''}>${messages.length ? messages : html `<p class="coding-meta coding-no-messages">No messages yet.</p>`}</div><button type="button" id="coding-latest" class="coding-secondary" hidden>Latest update ↓</button>${composer}<details id="coding-changes" class="coding-changes"><summary>Changes</summary><div id="coding-change-content"><p class="coding-meta">Open to load the current changes.</p></div><button type="button" id="coding-refresh-changes" class="coding-secondary">Refresh changes</button><noscript>${postForm(`${sessionUrl(s.id)}/changes`, html `<button type="submit" class="coding-secondary">View changes</button>`)}</noscript><p id="coding-shipping"${['closed', 'uncertain'].includes(s.status) ? html ` hidden` : ''}>${postForm(`${sessionUrl(s.id)}/ship/preview`, html `<button type="submit" class="coding-link-button">Review for shipping</button>`)}</p></details>`;
80
80
  }
81
81
  return html `<section class="coding-workspace" data-coding-session="${selected?.session.id ?? ''}" data-coding-owner="${selected?.session.owner ?? input.owner ?? ''}" data-coding-project="${selectedRepo ?? input.projects[0]?.path ?? ''}" data-coding-status="${selected?.session.status ?? ''}" data-coding-native-thread="${String(Boolean(selected?.session.nativeThreadId))}" data-coding-revision="${selected?.revision ?? -1}" data-coding-csrf="${csrf}">${sidebar}<div class="coding-main"><div id="coding-error" class="coding-notice" role="alert"${error ? '' : html ` hidden`}>${noticeHtml(error)}</div><p id="coding-connection" class="coding-meta" role="status" hidden></p>${main}</div></section>`;
82
82
  }
@@ -276,7 +276,7 @@ export function codingWorkspaceScript() {
276
276
  const area = root.querySelector('#coding-change-content');
277
277
  const button = root.querySelector('#coding-refresh-changes'); button.disabled = true;
278
278
  try {
279
- const response = await fetch(base + '/changes', {headers:{accept:'application/json'},credentials:'same-origin',cache:'no-store',signal:requestSignal()});
279
+ const response = await fetch(base + '/changes', {method:'POST',headers:{accept:'application/json'},body:new URLSearchParams({csrf}),credentials:'same-origin',cache:'no-store',signal:requestSignal()});
280
280
  if (response.status === 401 || response.redirected) { signedOut(); return; }
281
281
  if (!response.ok) throw new Error('Could not load changes. Try Refresh changes.');
282
282
  const changes = await response.json();
@@ -1,3 +1,4 @@
1
+ import { checkoutGit } from './teardown-git.js';
1
2
  import { createHash, randomUUID } from 'node:crypto';
2
3
  import { mkdirSync, chmodSync, realpathSync } from 'node:fs';
3
4
  import { dirname, join } from 'node:path';
@@ -729,10 +730,21 @@ export class CodingWorkspace {
729
730
  }
730
731
  async changes(id, actor) {
731
732
  const session = this.get(id, actor);
733
+ // The code page reads this on a GET (docs/plans/get-side-effects.md), from a checkout the agent writes: every git
734
+ // here is the hardened checkout git, which refuses a config that could run a command and reads no global config.
735
+ // Read mode: the read flags, and the checkout's config and info/attributes as read once (teardown-git.ts readView),
736
+ // hold even if the agent changes them after checkoutGit read them: no hook, pager, filter or network.
737
+ const safe = checkoutGit(run, 'read');
738
+ const read = async (args) => {
739
+ const result = await safe('git', args, { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 });
740
+ if (result.code !== 0)
741
+ throw Error('Git could not complete this workspace operation. Check the repository and try again.');
742
+ return result.stdout;
743
+ };
732
744
  const [head, status, tracked, untracked] = await Promise.all([
733
- this.git(session.worktree, ['rev-parse', 'HEAD']), this.git(session.worktree, ['status', '--short']),
734
- run('git', ['--no-optional-locks', 'diff', '--no-ext-diff', '--no-textconv', session.base, '--'], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 }),
735
- this.git(session.worktree, ['ls-files', '--others', '--exclude-standard', '-z']),
745
+ read(['rev-parse', 'HEAD']), read(['status', '--short']),
746
+ safe('git', ['diff', '--no-ext-diff', '--no-textconv', session.base, '--'], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 1_048_576 }),
747
+ read(['ls-files', '--others', '--exclude-standard', '-z']),
736
748
  ]);
737
749
  let diff = tracked.stdout, truncated = tracked.code !== 0;
738
750
  // git's no-index diff describes symlinks themselves; it does not follow a
@@ -743,7 +755,7 @@ export class CodingWorkspace {
743
755
  truncated = true;
744
756
  break;
745
757
  }
746
- const part = await run('git', ['--no-optional-locks', 'diff', '--no-index', '--no-ext-diff', '--no-textconv', '--', '/dev/null', paths[index]], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 131_072 });
758
+ const part = await safe('git', ['diff', '--no-index', '--no-ext-diff', '--no-textconv', '--', '/dev/null', paths[index]], { cwd: session.worktree, timeoutMs: 60_000, maxBuffer: 131_072 });
747
759
  diff += part.stdout;
748
760
  if (part.code !== 0 && part.code !== 1)
749
761
  truncated = true;
@@ -123,6 +123,9 @@ export declare const CONSOLE_FORMS: {
123
123
  readonly notifications: FormContract<"digest" | "mode" | "screenshots">;
124
124
  readonly notificationsMute: FormContract<"pings" | "repo">;
125
125
  readonly telegramDigest: FormContract<"every">;
126
+ readonly pullRequestsCheck: FormContract<"repo">;
127
+ readonly connectionCheck: FormContract<"auth-mode" | "provider" | "return" | "value">;
128
+ readonly modelsRefresh: FormContract<"command" | "fingerprint" | "model" | "nonce" | "provider" | "repo" | "seconds" | "task" | "token">;
126
129
  readonly providerKey: FormContract<"auth-mode" | "provider" | "value">;
127
130
  readonly telegramToken: FormContract<"token">;
128
131
  readonly email: FormContract<"from" | "host" | "imapHost" | "imapPort" | "password" | "port" | "secure" | "user">;
@@ -149,6 +152,7 @@ export declare const CONSOLE_FORMS: {
149
152
  readonly leadIdentity: FormContract<"name" | "persona">;
150
153
  readonly leadAbout: FormContract<"about">;
151
154
  readonly leadPromiseCancel: FormContract<"promise">;
155
+ readonly leadCheck: FormContract<"return">;
152
156
  readonly chatConfig: FormContract<"daily-turns" | "forget-key" | "key" | "model" | "off" | "provider" | "return" | "token" | "weekly-usd">;
153
157
  readonly leadMint: FormContract<"ceiling-usd" | "follow" | "return" | "token">;
154
158
  readonly leadFollow: FormContract<"enabled" | "return">;
@@ -190,7 +194,7 @@ export declare const CONSOLE_FORMS: {
190
194
  };
191
195
  export type ConsoleFormName = keyof typeof CONSOLE_FORMS;
192
196
  /** The console POSTs whose handlers read no field (sign-out not even the shared guard): their contract is the empty form. */
193
- export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review"];
197
+ export declare const BODILESS_POSTS: readonly ["POST /settings/models/<unknown action>", "POST /settings/lead/on", "POST /r/<run>/draft-repair", "POST /ledger/checkpoint", "POST /settings/backups/now", "POST /mode/revoke", "POST /settings/telegram/retry", "POST /providers/<provider>/resume", "POST /logout", "POST /t/<task>/unhold", "POST /t/<task>/stop-carrying", "POST /t/<task>/plan", "POST /t/<task>/retry-review", "POST /settings/updates/check", "POST /settings/storage/check", "POST /projects/github", "POST /code/<session>/ship/preview"];
194
198
  /** The field names a console form's handler may read. */
195
199
  export type FormFieldOf<N extends ConsoleFormName> = (typeof CONSOLE_FORMS)[N] extends FormContract<infer K> ? K : never;
196
200
  /** `GET /ledger?format=json`: the chain's state, a page of entries (newest first) and the cursor for the next. */
@@ -132,6 +132,10 @@ export const CONSOLE_FORMS = {
132
132
  notifications: formContract("POST /settings/notifications", ["mode", "digest", "screenshots"]),
133
133
  notificationsMute: formContract("POST /settings/notifications/mute", ["repo", "pings"]),
134
134
  telegramDigest: formContract("POST /settings/telegram-digest", ["every"]),
135
+ // Check again sits in the provider-key form and the setup card; Refresh models in the setup form.
136
+ pullRequestsCheck: formContract("POST /settings/pull-requests/check", ["repo"]),
137
+ connectionCheck: formContract("POST /settings/connection/check", ["provider", "auth-mode", "value", "return"]),
138
+ modelsRefresh: formContract("POST /control/models-refresh", ["repo", "nonce", "fingerprint", "token", "provider", "model", "command", "seconds", "task"]),
135
139
  providerKey: formContract("POST /settings/provider-key, /settings/provider-key-clear", ["provider", "auth-mode", "value"]),
136
140
  telegramToken: formContract("POST /settings/telegram-token", ["token"]),
137
141
  email: formContract("POST /settings/email, /settings/email-test, /settings/email-read-test", ["host", "port", "secure", "user", "from", "password", "imapHost", "imapPort"]),
@@ -158,6 +162,7 @@ export const CONSOLE_FORMS = {
158
162
  leadIdentity: formContract("POST /settings/lead/identity", ["name", "persona"]),
159
163
  leadAbout: formContract("POST /settings/lead/about", ["about"]),
160
164
  leadPromiseCancel: formContract("POST /settings/lead/promise/cancel", ["promise"]),
165
+ leadCheck: formContract("POST /lead/check", ["return"]),
161
166
  chatConfig: formContract("POST /chat/config", ["return", "token", "off", "forget-key", "provider", "model", "weekly-usd", "daily-turns", "key"]),
162
167
  leadMint: formContract("POST /chat/mate/mint", ["return", "ceiling-usd", "token", "follow"]),
163
168
  leadFollow: formContract("POST /chat/mate/follow", ["enabled", "return"]),
@@ -216,6 +221,11 @@ export const BODILESS_POSTS = [
216
221
  "POST /t/<task>/stop-carrying",
217
222
  "POST /t/<task>/plan",
218
223
  "POST /t/<task>/retry-review",
224
+ // Check now and its kin (docs/plans/get-side-effects.md): the form token only, no fields read.
225
+ "POST /settings/updates/check",
226
+ "POST /settings/storage/check",
227
+ "POST /projects/github",
228
+ "POST /code/<session>/ship/preview",
219
229
  ];
220
230
  // ---- the JSON API ------------------------------------------------------------------------------------------------
221
231
  const ledgerEntrySchema = z.looseObject({
@@ -6,14 +6,15 @@ import { type ModelChoice } from "./setup-guide.js";
6
6
  import type { SetupInputs } from "./control-setup.js";
7
7
  import { type OpenRouterModels } from "./openrouter-models.js";
8
8
  export declare function connectionWords(connection: ProviderConnection): string;
9
- export declare function connectionHtml(provider: ProviderId, connection: ProviderConnection, task?: string): Html;
9
+ /** The last sign-in check (null: none yet). Check again posts: a page never runs a check (docs/plans/get-side-effects.md). */
10
+ export declare function connectionHtml(provider: ProviderId, connection: ProviderConnection | null, task?: string): Html;
10
11
  export declare function controlSetupHtml(data: {
11
12
  repo: string;
12
13
  csrf: string;
13
14
  provider: ProviderId;
14
15
  inputs: SetupInputs;
15
16
  models: ModelChoice[];
16
- connection: ProviderConnection;
17
+ connection: ProviderConnection | null;
17
18
  catalog: OpenRouterModels | null;
18
19
  task?: string;
19
20
  preparation: {
@@ -8,15 +8,20 @@ export function connectionWords(connection) {
8
8
  unverified: "Not verified", "key-present": "API key saved · not verified", "key-works": "API key works", "key-refused": "API key refused", "missing-key": "API key needed",
9
9
  }[connection.state];
10
10
  }
11
+ /** The last sign-in check (null: none yet). Check again posts: a page never runs a check (docs/plans/get-side-effects.md). */
11
12
  export function connectionHtml(provider, connection, task = "") {
12
13
  const query = new URLSearchParams({ provider, ...(task ? { task } : {}) });
13
- return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · ${connectionWords(connection)}</strong></p><p class="meta">${[connection.email, connection.plan, connection.method].filter(Boolean).join(" · ")}</p><p class="meta">Checked ${new Date(connection.checkedAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" })} on the computer running this console. Sign-in does not prove model access or remaining quota.</p><a class="button-link" href="/control/connection?${query.toString()}">${connection.state === "connected" ? "Manage connection" : "Connect account"}</a> · <a href="/control?${query.toString()}&amp;check-connection=1">Check again</a></div>`;
14
+ const back = `/control?${query.toString()}`;
15
+ const check = postForm("/settings/connection/check", html `<button type="submit" class="secondary">${connection === null ? "Check now" : "Check again"}</button>`, { attrs: { class: "inline" }, returnTo: back, hidden: { provider } });
16
+ if (connection === null)
17
+ return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · Not checked yet</strong></p>${check}</div>`;
18
+ return html `<div class="card assistant-account"><p><strong>${ASSISTANTS[provider].name} · ${connectionWords(connection)}</strong></p><p class="meta">${[connection.email, connection.plan, connection.method].filter(Boolean).join(" · ")}</p><p class="meta">Checked ${new Date(connection.checkedAt).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", timeZoneName: "short" })} on the computer running this console. Sign-in does not prove model access or remaining quota.</p><a class="button-link" href="/control/connection?${query.toString()}">${connection.state === "connected" ? "Manage connection" : "Connect account"}</a> ${check}</div>`;
14
19
  }
15
20
  export function controlSetupHtml(data) {
16
21
  const { provider, inputs, task = "" } = data;
17
22
  const query = new URLSearchParams({ provider, ...(task ? { task } : {}) });
18
23
  const model = data.catalog !== null
19
- ? openRouterPicker(data.catalog, inputs.model || null, `/control?${query}&refresh-models=1`)
24
+ ? openRouterPicker(data.catalog, inputs.model || null, "/control/models-refresh")
20
25
  : html `<label>Model<input type="text" name="model" value="${inputs.model}" list="setup-models" required autocomplete="off" placeholder="Exact model id or supported alias"></label><datalist id="setup-models">${data.models.map(one => html `<option value="${one.value}">${one.label}</option>`)}</datalist>`;
21
26
  return html `<h1>Project setup</h1><p>${data.repo}</p><p class="meta">Choose the default builder for new tasks and the preparation command for this project.</p><nav class="assistant-picker row" aria-label="Choose provider">${joinHtml(Object.keys(ASSISTANTS).map(id => html `<a class="button-link" href="/control?${new URLSearchParams({ provider: id, ...(task ? { task } : {}) }).toString()}"${id === provider ? html ` aria-current="page"` : ""}>${ASSISTANTS[id].name}</a>`), " ")}</nav>${connectionHtml(provider, data.connection, task)}${postForm("/control/setup-preview", html `${model}<details class="setup-advanced"><summary>Project preparation</summary>${data.preparation === null ? "" : html `<p class="meta">Suggested from ${data.preparation.evidence}: ${data.preparation.label} — <code>${data.preparation.command}</code>. Review before approving.</p>`}<label>Preparation command<textarea name="command" rows="2" maxlength="2000">${inputs.command}</textarea></label><label>Preparation timeout in seconds<input name="seconds" type="number" min="1" max="3600" value="${inputs.seconds}" required></label><p class="meta">Preparation applies to subsequent runs, including already approved tasks. Saving setup does not execute it.</p></details><button type="submit">Review project setup</button>`, { attrs: { class: "card" }, hidden: { repo: data.repo, provider, task } })}<section class="card"><h2>Agent instructions</h2>${data.instructions.message ? html `<p>${data.instructions.message}</p>` : data.instructions.installed ? html `<p>Toolroll instructions are installed.</p>` :
22
27
  html `<p>Give the agent the project's Toolroll handoff instructions.</p>${postForm("/control/instructions-preview", html `<button>Review instructions</button>`, { hidden: { repo: data.repo } })}`}</section><p><a href="/fleet">Configure planning, repair, and review agents</a> · <a href="/tasks/new">Describe a task</a></p>`;
package/dist/demo.js CHANGED
@@ -1481,7 +1481,7 @@ export function createDemoLead(input) {
1481
1481
  try {
1482
1482
  advance(clock());
1483
1483
  }
1484
- catch { /* the next read advances it again */ }
1484
+ catch { /* the next beat or background pass advances it again */ }
1485
1485
  }, beat * stepMs + 20);
1486
1486
  timer.unref();
1487
1487
  timers.add(timer);
@@ -1489,8 +1489,10 @@ export function createDemoLead(input) {
1489
1489
  void exchange;
1490
1490
  };
1491
1491
  return {
1492
- exchanges: () => { advance(clock()); return list; },
1493
- version: () => { advance(clock()); return changes; },
1492
+ // Reads only (docs/plans/get-side-effects.md): the beat timers, the console's background pass and the person's own
1493
+ // acts move the story on, never a page visit.
1494
+ exchanges: () => list,
1495
+ version: () => changes,
1494
1496
  ask(text, now) {
1495
1497
  advance(now);
1496
1498
  const asked = said(text);
@@ -92,6 +92,9 @@ export declare function previewDesktopUpdate(stateDir: string, installed: string
92
92
  }>;
93
93
  export declare function prepareDesktopUpdate(stateDir: string, installed: string, candidate: string, label: string, expected: string, hooks?: UpdateHooks): Promise<UpdateJournal>;
94
94
  export declare function sqliteLock(file: string, busyTimeoutMs?: number): DatabaseSync | null;
95
+ /** Whether another process holds the lock in `file`, without making the file or changing it: a status read (a page
96
+ * shows it, docs/plans/get-side-effects.md). A file that isn't there yet is held by nobody. */
97
+ export declare function lockHeld(file: string): boolean;
95
98
  export declare const installationLock: (j: UpdateJournal) => DatabaseSync | null;
96
99
  export declare function desktopUpdateStatus(stateDir: string): {
97
100
  version?: never;
@@ -1,3 +1,4 @@
1
+ import { inReadOnlyRequest } from "./read-only-request.js";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { closeSync, chmodSync, copyFileSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { homedir } from "node:os";
@@ -261,11 +262,14 @@ export function desktopUpdateWaitingOf(databaseFile, inTheWay, stateDirs = [dirn
261
262
  }
262
263
  if (!j || resolve(j.databaseFile) !== resolve(databaseFile))
263
264
  continue;
264
- try {
265
- if (releaseStalledDesktopUpdate(stateDir))
266
- j = readUpdateJournal(stateDir);
265
+ // A page visit only reads (docs/plans/get-side-effects.md): the console's background pass releases it.
266
+ if (!inReadOnlyRequest()) {
267
+ try {
268
+ if (releaseStalledDesktopUpdate(stateDir))
269
+ j = readUpdateJournal(stateDir);
270
+ }
271
+ catch { /* shown as last saved */ }
267
272
  }
268
- catch { /* shown as last saved */ }
269
273
  const version = j.next.version;
270
274
  if (j.phase === "draining")
271
275
  return updateWaitingWords({ app: true, version, stopped: false, lingering: j.waiting ?? null });
@@ -394,14 +398,46 @@ export function sqliteLock(file, busyTimeoutMs = 0) {
394
398
  throw error;
395
399
  }
396
400
  }
401
+ /** Whether another process holds the lock in `file`, without making the file or changing it: a status read (a page
402
+ * shows it, docs/plans/get-side-effects.md). A file that isn't there yet is held by nobody. */
403
+ export function lockHeld(file) {
404
+ let st;
405
+ try {
406
+ st = lstatSync(file);
407
+ }
408
+ catch {
409
+ return false;
410
+ }
411
+ // Not a plain file of ours (a link, or one someone else made): treated as held, so nothing goes ahead on it.
412
+ if (!st.isFile() || (typeof process.getuid === "function" && st.uid !== process.getuid()))
413
+ return true;
414
+ let db;
415
+ // Read-only: it never makes the file. The holder's exclusive lock stops even a read.
416
+ try {
417
+ db = new (sqlite().DatabaseSync)(file, { readOnly: true });
418
+ }
419
+ catch {
420
+ return true;
421
+ }
422
+ try {
423
+ db.exec("PRAGMA busy_timeout=0");
424
+ db.prepare("SELECT COUNT(*) FROM sqlite_master").get();
425
+ return false;
426
+ }
427
+ catch (error) {
428
+ return String(error).includes("locked") || String(error).includes("busy") || [5, 6].includes(error.errcode ?? 0);
429
+ }
430
+ finally {
431
+ db.close();
432
+ }
433
+ }
397
434
  const workerLock = (j, busyTimeoutMs = 0) => sqliteLock(join(j.workDir, "worker.sqlite"), busyTimeoutMs);
398
435
  export const installationLock = (j) => sqliteLock(join(dirname(j.workDir), `installation-${digest(j.old.path).slice(0, 24)}.sqlite`));
399
436
  export function desktopUpdateStatus(stateDir) {
400
437
  const j = readUpdateJournal(stateDir);
401
438
  if (!j)
402
439
  return { active: false, phase: "none", detail: "No update is in progress.", running: false, canResume: false, canCancel: false };
403
- const lock = workerLock(j), running = lock === null;
404
- lock?.close();
440
+ const running = lockHeld(join(j.workDir, "worker.sqlite"));
405
441
  let recovery = null;
406
442
  try {
407
443
  privateFile(join(j.workDir, "recovery.json"));
@@ -409,9 +445,7 @@ export function desktopUpdateStatus(stateDir) {
409
445
  }
410
446
  catch { /* A legacy/manual update has no guardian. */ }
411
447
  const wantsAutomatic = recovery?.id === j.id && ["armed", "running", "backoff"].includes(recovery.state ?? "") && !terminal(j.phase);
412
- const guardianLock = wantsAutomatic ? sqliteLock(join(j.workDir, "guardian.sqlite")) : undefined;
413
- const automatic = wantsAutomatic && (guardianLock === null || Date.now() - Date.parse(recovery?.updatedAt ?? "") < 45_000);
414
- guardianLock?.close();
448
+ const automatic = wantsAutomatic && (lockHeld(join(j.workDir, "guardian.sqlite")) || Date.now() - Date.parse(recovery?.updatedAt ?? "") < 45_000);
415
449
  const detail = wantsAutomatic && !automatic ? "Automatic recovery has not restarted. Work remains paused; use Retry safely in Update status." : recovery?.state === "attention" || (automatic && (!running || j.phase === "needs-attention")) ? recovery?.detail ?? j.detail : j.detail;
416
450
  return { active: !terminal(j.phase), phase: j.phase, detail, error: j.error ?? null, running, automaticRecovery: automatic, recoveryAttempts: recovery?.attempts ?? 0, recoveryRepairs: recovery?.repairs ?? 0, canResume: !running && !automatic && !terminal(j.phase), canCancel: !terminal(j.phase), wasRunning: j.wasRunning && !updateStopRequested(j), backupPath: j.backupPath ?? null, updateId: j.id, version: j.next.version, buildId: j.next.buildId, workDir: j.workDir };
417
451
  }
@@ -94,14 +94,17 @@ export declare function firstTaskJourney(read: {
94
94
  stage: "queued" | "planning" | "needs-you" | "waiting" | "building" | "checking" | "reviewing" | "finished" | "complete" | "failed" | "stopped";
95
95
  need?: string | undefined;
96
96
  }, approved: boolean, built?: boolean): JourneyStep[];
97
- /** What a task's checks recorded, for its road: `null` when nothing says (the step is then shown skipped, never done). */
98
- export type RoadChecks = "passed" | "running" | "failed" | "not-run" | "unread" | null;
97
+ /** The approved check as the task's status reads it (task-status.ts `approvedCheckStateOf`), for its road: `null` when
98
+ * nothing says (the step is then shown skipped, never done). The road reads this one answer, never a row's words. */
99
+ export type RoadChecks = "passed" | "running" | "failed" | "not-run" | "off" | "unreadable" | null;
99
100
  /**
100
101
  * Any task's road (the read-only stepper on its page, after ReUI's Stepper): the first task's five steps, told only
101
102
  * from recorded facts. You approve is done only when the plan was approved (a rewritten plan waiting on approval
102
- * stands there again); a task that never needed one shows it skipped. Checks is done only when a check passed; a
103
- * running, failed, missing or unread check says so, and with no checks fact it is skipped. A research report has
104
- * no Checks step: a report stopped there stops at Ready. The step that waits on a person carries `needsYou`.
103
+ * stands there again); a task that never needed one shows it skipped. Checks is done only when the approved check
104
+ * passed; a running, missing or Off check says so, and with no checks fact it is skipped. A failed check, or one that
105
+ * can't be read, stops the road at Checks: Ready isn't done (fail closed), even on a task a person marked complete.
106
+ * A research report has no Checks step: a report stopped there stops at
107
+ * Ready. The step that waits on a person carries `needsYou`.
105
108
  */
106
109
  export declare function taskRoad(read: Parameters<typeof firstTaskJourney>[0], approved: boolean, built: boolean, facts: {
107
110
  report?: boolean;
package/dist/first-run.js CHANGED
@@ -144,31 +144,64 @@ export function firstTaskJourney(read, approved, built = false) {
144
144
  /**
145
145
  * Any task's road (the read-only stepper on its page, after ReUI's Stepper): the first task's five steps, told only
146
146
  * from recorded facts. You approve is done only when the plan was approved (a rewritten plan waiting on approval
147
- * stands there again); a task that never needed one shows it skipped. Checks is done only when a check passed; a
148
- * running, failed, missing or unread check says so, and with no checks fact it is skipped. A research report has
149
- * no Checks step: a report stopped there stops at Ready. The step that waits on a person carries `needsYou`.
147
+ * stands there again); a task that never needed one shows it skipped. Checks is done only when the approved check
148
+ * passed; a running, missing or Off check says so, and with no checks fact it is skipped. A failed check, or one that
149
+ * can't be read, stops the road at Checks: Ready isn't done (fail closed), even on a task a person marked complete.
150
+ * A research report has no Checks step: a report stopped there stops at
151
+ * Ready. The step that waits on a person carries `needsYou`.
150
152
  */
151
153
  export function taskRoad(read, approved, built, facts) {
152
154
  const replan = read.stage === "needs-you" && read.need === "replan";
153
155
  const plainApproved = approved && !replan;
154
156
  let steps = firstTaskJourney(replan ? { stage: "needs-you", need: "approval" } : read, plainApproved, built);
157
+ const complete = read.stage === "complete";
158
+ // A finished build that waits on something other than its check (a hold, a decision) stops after Checks, at Ready,
159
+ // when its check passed, didn't run or is Off: the road never calls a passing check stuck.
160
+ let waitsAtReady = false;
155
161
  steps = steps.map(step => {
156
162
  if (step.key === "approve" && step.state === "done" && !plainApproved)
157
163
  return { ...step, state: "skipped" };
164
+ if (step.key === "checks" && step.state === "stuck" && built) {
165
+ switch (facts.checks ?? null) {
166
+ case "passed":
167
+ waitsAtReady = true;
168
+ return { ...step, state: "done" };
169
+ case "not-run":
170
+ waitsAtReady = true;
171
+ return { ...step, state: "skipped", label: "Checks not run" };
172
+ case "off":
173
+ waitsAtReady = true;
174
+ return { ...step, state: "skipped", label: "Checks at release" };
175
+ case "running": return { ...step, state: "current", label: "Checks running" };
176
+ case "failed": return { ...step, label: "Checks failed" };
177
+ case "unreadable": return { ...step, label: "Checks unreadable" };
178
+ default: return step;
179
+ }
180
+ }
158
181
  if (step.key !== "checks" || (step.state !== "done" && step.state !== "current"))
159
182
  return step;
160
- // At Checks now: only a recorded failure or a running check changes how it reads.
183
+ // At Checks now: only a recorded failure, an unreadable check or a running check changes how it reads.
161
184
  if (step.state === "current")
162
- return facts.checks === "failed" ? { ...step, state: "stuck", label: "Checks failed" } : facts.checks === "running" ? { ...step, label: "Checks running" } : step;
185
+ return facts.checks === "failed" ? { ...step, state: "stuck", label: "Checks failed" }
186
+ : facts.checks === "unreadable" ? { ...step, state: "stuck", label: "Checks unreadable" }
187
+ : facts.checks === "running" ? { ...step, label: "Checks running" } : step;
163
188
  switch (facts.checks ?? null) {
164
189
  case "passed": return step;
165
190
  case "running": return { ...step, state: "current", label: "Checks running" };
166
191
  case "failed": return { ...step, state: "stuck", label: "Checks failed" };
167
192
  case "not-run": return { ...step, state: "skipped", label: "Checks not run" };
168
- case "unread": return { ...step, state: "skipped", label: "Checks unread" };
193
+ case "off": return { ...step, state: "skipped", label: "Checks at release" };
194
+ case "unreadable": return { ...step, state: "stuck", label: "Checks unreadable", ...(complete ? {} : { needsYou: true }) };
169
195
  default: return { ...step, state: "skipped" };
170
196
  }
171
197
  });
198
+ if (waitsAtReady)
199
+ steps = steps.map(step => step.key === "ready" ? { ...step, state: "stuck" } : step);
200
+ // A road stopped at Checks (failed, or unreadable) has not reached Ready.
201
+ const at = steps.find(step => step.key === "checks");
202
+ if (facts.report !== true && at !== undefined && at.state === "stuck") {
203
+ steps = steps.map(step => step.key === "ready" && step.state === "done" ? { ...step, state: "todo" } : step);
204
+ }
172
205
  if (facts.report === true) {
173
206
  const checks = steps.find(step => step.key === "checks");
174
207
  if (checks !== undefined && (checks.state === "current" || checks.state === "stuck")) {
@@ -32,8 +32,9 @@ export function flowCardTaskLine(store, taskId, repo, now, secrets = []) {
32
32
  // The canvas reads no evidence files. Keep the machine's recorded check visible
33
33
  // when the full receipt has no artifact root from which to read its check.
34
34
  const check = assignment.receipt === null ? null : store.runCheckFor(assignment.receipt.runId);
35
- if (facts.checks?.status === "unavailable" && check !== null)
36
- facts.checks = { ...facts.checks, status: check.status, exitCode: check.exitCode };
35
+ // With neither, nothing about the check was read here: the line says nothing about it, never a pass and never an ask.
36
+ if (facts.checks?.status === "unavailable")
37
+ facts.checks = check === null ? null : { ...facts.checks, status: check.status, exitCode: check.exitCode };
37
38
  const status = taskStatusOf(facts);
38
39
  if (status.headline === "Needs you") {
39
40
  const fallback = status.need?.action.label ?? "Open the task";
@@ -99,6 +99,8 @@ export declare function endUnattendedGrants(store: Store, filter: {
99
99
  /** The live grant for a flow, or null. One past its expiry is closed here, durably (like activeMode). A grant whose
100
100
  * signer is no longer an active approver, or whose flow moved on, covers nothing. */
101
101
  export declare function activeUnattendedGrant(store: Store, flowId: number, now: Date): UnattendedGrant | null;
102
+ /** Close every grant past its expiry (the console's background pass). Returns how many ended. */
103
+ export declare function closeExpiredUnattendedGrants(store: Store, now: Date): number;
102
104
  /** sha256 over a trigger's identity and canonical settings: what a grant freezes for each schedule it covers. */
103
105
  export declare function triggerDigestOf(trigger: Pick<FlowTriggerRow, "id" | "flow" | "kind" | "configJson">): string | null;
104
106
  /** Asynchronous preparation, never called from filing transactions or page rendering. */
@@ -24,6 +24,7 @@
24
24
  * This module owns the terms, digest, words and transactions; the flow engine
25
25
  * asks for coverage inside its filing transaction, and plan-auto.ts seals.
26
26
  */
27
+ import { inReadOnlyRequest } from "./read-only-request.js";
27
28
  import { spawn } from "node:child_process";
28
29
  import { createHash } from "node:crypto";
29
30
  import { lstat, readFile, realpath, stat } from "node:fs/promises";
@@ -231,11 +232,21 @@ export function activeUnattendedGrant(store, flowId, now) {
231
232
  if (row === undefined)
232
233
  return null;
233
234
  if (Date.parse(String(row["absolute_expiry"])) <= now.getTime()) {
234
- store.transact(() => closeGrant(store, row, "the-clock", "expired", now));
235
+ // A page visit only reads (docs/plans/get-side-effects.md): the background pass or the next path that acts closes it.
236
+ if (!inReadOnlyRequest())
237
+ store.transact(() => closeGrant(store, row, "the-clock", "expired", now));
235
238
  return null;
236
239
  }
237
240
  return readGrant(row);
238
241
  }
242
+ /** Close every grant past its expiry (the console's background pass). Returns how many ended. */
243
+ export function closeExpiredUnattendedGrants(store, now) {
244
+ const rows = store.handle.prepare("SELECT * FROM flow_unattended_grant WHERE revoked_at IS NULL").all()
245
+ .filter(row => Date.parse(String(row["absolute_expiry"])) <= now.getTime());
246
+ for (const row of rows)
247
+ store.transact(() => closeGrant(store, row, "the-clock", "expired", now));
248
+ return rows.length;
249
+ }
239
250
  /** Why the grant can't cover this flow right now (the same checks signing makes), or null. */
240
251
  function coverageProblem(store, flow, owner, workRepos) {
241
252
  if (flow.state !== "active")
@@ -114,14 +114,15 @@ export declare function secretsNote(list: readonly Integration[], secrets: Secre
114
114
  * files only, or why they can't be used (secretsNote). */
115
115
  export declare function renderIntegrations(list: readonly Integration[], secrets?: SecretsKept | null): string[];
116
116
  export type IntegrationMonitor = {
117
- /** The list now, without waiting; starts a background check when the last one is older than the cache. */
117
+ /** The list now, from the last answers saved. It only reads: a page calls it on every render (docs/plans/get-side-effects.md). */
118
118
  list(): Integration[];
119
- /** Check now and wait (Send test, or the first answer in a test). */
119
+ /** Check now and wait (Send test, Check now, or the first answer in a test). */
120
120
  check(keys?: readonly string[]): Promise<Integration[]>;
121
+ /** The background pass: start a check when the last one is older than the cache. Never called from a page. */
122
+ refreshIfDue(): void;
121
123
  /** Whether a background check is running. */
122
124
  readonly checking: boolean;
123
125
  };
124
- /** Checks in the background with a short cache, as the first-run suggestions are: a render never waits on a check. */
125
126
  export declare function createIntegrationMonitor(io: () => IntegrationIo, options?: {
126
127
  cacheMs?: number;
127
128
  now?: () => number;