toolroll 0.9.73 → 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 (145) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-turns.js +5 -0
  4. package/dist/agents-live.d.ts +31 -0
  5. package/dist/agents-live.js +43 -0
  6. package/dist/assignment.d.ts +3 -0
  7. package/dist/assignment.js +27 -8
  8. package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
  9. package/dist/browser/workspace.css +2 -2
  10. package/dist/browser/workspace.js +31 -30
  11. package/dist/browser-workspace.d.ts +7 -1
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-acceptance.d.ts +4 -1
  14. package/dist/chat-acceptance.js +13 -3
  15. package/dist/chat-continuity.js +10 -0
  16. package/dist/chat-quiet.js +3 -3
  17. package/dist/cli.js +1 -1
  18. package/dist/coding-handoff.js +4 -3
  19. package/dist/coding-ui.js +2 -2
  20. package/dist/coding-workspace.js +16 -4
  21. package/dist/contracts/console-api.d.ts +5 -1
  22. package/dist/contracts/console-api.js +10 -0
  23. package/dist/control-ui.d.ts +3 -2
  24. package/dist/control-ui.js +7 -2
  25. package/dist/demo.js +5 -3
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +4 -1
  28. package/dist/desktop-update.js +43 -9
  29. package/dist/dispose.js +35 -2
  30. package/dist/exec.js +13 -16
  31. package/dist/first-run.d.ts +20 -1
  32. package/dist/first-run.js +71 -0
  33. package/dist/flow-card-task.js +3 -2
  34. package/dist/flow-unattended.d.ts +2 -0
  35. package/dist/flow-unattended.js +12 -1
  36. package/dist/integrations.d.ts +4 -3
  37. package/dist/integrations.js +5 -6
  38. package/dist/invoke.d.ts +13 -0
  39. package/dist/invoke.js +21 -12
  40. package/dist/knowledge-ui.d.ts +4 -1
  41. package/dist/knowledge-ui.js +10 -5
  42. package/dist/lead-contract.d.ts +1 -1
  43. package/dist/lead-contract.js +2 -2
  44. package/dist/lead-follow.js +3 -1
  45. package/dist/lead-progress.d.ts +4 -1
  46. package/dist/lead-progress.js +3 -1
  47. package/dist/lead.js +4 -0
  48. package/dist/live-bus.d.ts +2 -0
  49. package/dist/live-bus.js +1 -0
  50. package/dist/live-children-read.d.ts +52 -0
  51. package/dist/live-children-read.js +333 -0
  52. package/dist/live-children.d.ts +73 -0
  53. package/dist/live-children.js +318 -0
  54. package/dist/live-detail.d.ts +4 -3
  55. package/dist/live-detail.js +9 -5
  56. package/dist/live-input.js +7 -8
  57. package/dist/live-main-read.d.ts +19 -0
  58. package/dist/live-main-read.js +91 -0
  59. package/dist/live-open.d.ts +28 -0
  60. package/dist/live-open.js +125 -0
  61. package/dist/live-records.d.ts +10 -1
  62. package/dist/live-records.js +16 -5
  63. package/dist/live.d.ts +54 -22
  64. package/dist/live.js +218 -69
  65. package/dist/openrouter-models.d.ts +5 -2
  66. package/dist/openrouter-models.js +7 -4
  67. package/dist/operate.js +5 -2
  68. package/dist/peek-cli.d.ts +10 -3
  69. package/dist/peek-cli.js +17 -8
  70. package/dist/planner.js +1 -1
  71. package/dist/project-learning.js +44 -6
  72. package/dist/project-tools.js +19 -1
  73. package/dist/provider-limits.d.ts +3 -1
  74. package/dist/provider-limits.js +5 -2
  75. package/dist/provider.js +6 -12
  76. package/dist/pull-request-ui.d.ts +1 -1
  77. package/dist/pull-request-ui.js +5 -1
  78. package/dist/push.d.ts +2 -0
  79. package/dist/push.js +24 -19
  80. package/dist/read-only-request.d.ts +4 -0
  81. package/dist/read-only-request.js +15 -0
  82. package/dist/release-adapters.js +4 -0
  83. package/dist/release.d.ts +3 -0
  84. package/dist/release.js +3 -0
  85. package/dist/repository-context-ui.js +2 -1
  86. package/dist/repository-context.js +6 -1
  87. package/dist/result-follow-ups.js +20 -1
  88. package/dist/result-review.js +5 -1
  89. package/dist/run-efficiency.d.ts +3 -3
  90. package/dist/run-efficiency.js +20 -8
  91. package/dist/scout.d.ts +3 -0
  92. package/dist/scout.js +5 -3
  93. package/dist/serve.js +196 -29
  94. package/dist/server/agents-live-filed.d.ts +17 -0
  95. package/dist/server/agents-live-filed.js +97 -0
  96. package/dist/server/agents-live-lead.d.ts +47 -0
  97. package/dist/server/agents-live-lead.js +116 -0
  98. package/dist/server/agents-live-room.d.ts +23 -0
  99. package/dist/server/agents-live-room.js +91 -0
  100. package/dist/server/chat.js +115 -53
  101. package/dist/server/chrome.js +2 -2
  102. package/dist/server/console.css +68 -40
  103. package/dist/server/http.d.ts +5 -0
  104. package/dist/server/last-checks.d.ts +17 -0
  105. package/dist/server/last-checks.js +22 -0
  106. package/dist/server/live.d.ts +4 -0
  107. package/dist/server/live.js +42 -2
  108. package/dist/server/pages.js +98 -4
  109. package/dist/server/people-tokens.js +7 -4
  110. package/dist/server/render-chat.d.ts +15 -1
  111. package/dist/server/render-chat.js +14 -7
  112. package/dist/server/render-pages.d.ts +10 -5
  113. package/dist/server/render-pages.js +83 -74
  114. package/dist/server/render-results.d.ts +11 -2
  115. package/dist/server/render-results.js +54 -12
  116. package/dist/server/render-settings.js +3 -3
  117. package/dist/server/render-tasks.js +13 -3
  118. package/dist/server/route-table.js +19 -2
  119. package/dist/server/runtime.d.ts +17 -2
  120. package/dist/server/settings.js +85 -32
  121. package/dist/server/task-views.js +1 -2
  122. package/dist/server/tasks.js +70 -43
  123. package/dist/storage-ui.d.ts +2 -0
  124. package/dist/storage-ui.js +1 -1
  125. package/dist/storage.js +2 -1
  126. package/dist/store.d.ts +34 -1
  127. package/dist/store.js +108 -11
  128. package/dist/stream-scope.d.ts +18 -0
  129. package/dist/stream-scope.js +28 -0
  130. package/dist/subagent-work.js +3 -0
  131. package/dist/subscription-chat.js +5 -3
  132. package/dist/task-status.d.ts +28 -1
  133. package/dist/task-status.js +93 -11
  134. package/dist/teardown-git.d.ts +27 -1
  135. package/dist/teardown-git.js +185 -39
  136. package/dist/telegram-progress.js +4 -4
  137. package/dist/telegram-status.js +8 -3
  138. package/dist/tool-secret-store.js +8 -0
  139. package/dist/toolroll-update-ui.d.ts +2 -0
  140. package/dist/toolroll-update-ui.js +4 -3
  141. package/dist/toolroll-update.js +10 -9
  142. package/dist/work-index.js +29 -12
  143. package/dist/workspace-ui.d.ts +5 -0
  144. package/dist/workspace-ui.js +38 -10
  145. package/package.json +1 -1
@@ -211,6 +211,10 @@ export type BrowserTasksView = {
211
211
  newTask: BrowserLink;
212
212
  /** v105: subscription windows and monthly budgets, one tile each (an instance operator's; null when there are none). */
213
213
  limits: BrowserLimits | null;
214
+ /** The stored-state filter (Any state, Queued, Running, Done, Failed, Cancelled), each a real link starting on page one. */
215
+ states?: (BrowserLink & {
216
+ active: boolean;
217
+ })[];
214
218
  };
215
219
  /** One limit: a plan's usage window ("Claude · 5-hour, 48%") or a monthly budget ("shop · Budget, $4.20 of $10"). */
216
220
  export type BrowserTaskGroup = WorkIndexGroup;
@@ -272,7 +276,7 @@ export type BrowserSettingsView = {
272
276
  connection: {
273
277
  words: string;
274
278
  facts: string;
275
- checkHref: string;
279
+ checkAction: string;
276
280
  } | null;
277
281
  usage: string;
278
282
  envName: string;
@@ -1445,6 +1449,8 @@ export type BrowserAgentsView = {
1445
1449
  selected: BrowserAgentDetail | null;
1446
1450
  /** Shown when nothing is on the board. */
1447
1451
  empty: string | null;
1452
+ /** The Live grid (?view=live): every running agent's live output, watching only. */
1453
+ live?: boolean;
1448
1454
  };
1449
1455
  export type BrowserView = BrowserTasksView | BrowserSettingsView | BrowserTaskView | BrowserProjectsView | BrowserResultView | BrowserFlowView | BrowserAgentsView;
1450
1456
  export type BrowserWorkspace = {
package/dist/builder.js CHANGED
@@ -1240,7 +1240,7 @@ export async function build(store, request) {
1240
1240
  // Every streaming transport emits events now (peek); a file that cannot
1241
1241
  // open is a null, and a null never costs a build.
1242
1242
  // What the agent did last rides the same stream into one row (live tasks).
1243
- const liveLog = withAgentTurns(withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree)), store, request.runId, clock), (state, at) => { if (attemptKey !== null)
1243
+ const liveLog = withAgentTurns(withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock())), store, request.runId, clock), (state, at) => { if (attemptKey !== null)
1244
1244
  recordAgentTurn(store, { run: request.runId, attemptKey, state, at }); }, clock, (open, at) => { if (attemptKey !== null)
1245
1245
  noteOpenTool(store, request.runId, attemptKey, open, at); });
1246
1246
  // The retry base (steering fix for run 1465's proof): the SAME pinned
@@ -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,
@@ -125,6 +125,8 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
125
125
  if(got===null)return;
126
126
  var data=got.data||{};
127
127
  if(data.ok===true){
128
+ // A conversation shown before it existed (session 0) is started by its first send: that is this one.
129
+ if(session==='0'&&typeof data.session==='number'&&data.session>0)adopt(String(data.session));
128
130
  if(String(data.session)!==session){changed();return;}
129
131
  say(data.queued===true?'Queued. It goes to the lead after the reply in progress.':'Sent. Waiting for the reply…');later(300);return;
130
132
  }
@@ -273,6 +275,9 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
273
275
  }
274
276
  return true;
275
277
  }
278
+ // The conversation this page's first send started: its binding and its draft move to it (a reload restores from there).
279
+ function adopt(started){session=started;document.querySelectorAll('form[action="/chat"] [name="request-session"]').forEach(function(bound){bound.value=started;});
280
+ try{sessionStorage.removeItem(key);}catch(e){}key='standing-orders:chat-draft:'+session+':'+task;save();}
276
281
  function later(ms){if(stale)return;clearTimeout(timer);timer=setTimeout(check,ms);}
277
282
  async function check(){
278
283
  if(polling||stale)return;
@@ -289,6 +294,11 @@ export const CHAT_CONTINUITY_SCRIPT = String.raw `
289
294
  if(!r.ok)throw new Error('connection');
290
295
  var data=await r.json();
291
296
  if(!valid(data))throw new Error('response');
297
+ // Not started yet (session 0): only this send's own receipt in the started conversation proves it is the one.
298
+ if(session==='0'&&typeof data.session==='number'&&data.session>0&&asked&&data.received===true)adopt(String(data.session));
299
+ // A poll that lands while the first send is on its way decides nothing: that send's answer (or the poll it
300
+ // asks for afterwards, with its request) says whether the started conversation is this page's.
301
+ if(session==='0'&&sending&&data.session!==null&&String(data.session)!=='0'){later(1000);return;}
292
302
  if(data.session===null||String(data.session)!==session){changed();return;}
293
303
  // A late answer for another lens never lands here; the poll goes on.
294
304
  if(data.task!==task){later(5000);return;}
@@ -101,7 +101,7 @@ export function quietCardView(store, taskRefs, now, root, viewer) {
101
101
  const heading = `${lines.length} tasks`;
102
102
  const projects = [...new Set(lines.map(one => projectLabel(one.project)))];
103
103
  const text = [heading, ...lines.map(one => `${one.icon} ${phoneText(one.title, 60)} · ${one.status}`), "", projects.join(", ")].join("\n");
104
- return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/tasks" } };
104
+ return { text, entities: [{ type: "bold", offset: 0, length: heading.length }], link: { label: "Open tasks", path: "/work" } };
105
105
  }
106
106
  /** Facts that carry their own controls or must stand alone (a security alert, a decision, a flow card, a
107
107
  * screenshot, an acceptance packet) are never folded into a batch. */
@@ -176,7 +176,7 @@ export function finishedView(store, batch, now, root, viewer) {
176
176
  return null;
177
177
  const text = batchLine(lines.map(one => one.fact));
178
178
  if (lines.length > 1)
179
- return { text, entities: [], link: { label: "Open", path: "/tasks" } };
179
+ return { text, entities: [], link: { label: "Open", path: "/work" } };
180
180
  const target = lines[0].target ?? null;
181
181
  return { text, entities: [], link: lines[0].link, ...(lines[0].also.length === 0 ? {} : { also: lines[0].also }), ...(target === null ? {} : { target }) };
182
182
  }
@@ -242,7 +242,7 @@ export function enqueueEveningDigests(store, now, root) {
242
242
  if (body === null)
243
243
  return;
244
244
  if (store.enqueueNotification({ dedupeKey: `digest:evening:${person.account}:${day}`, kind: "evening-digest", subject: "Evening digest", body,
245
- link: "/tasks", source: { installation: true }, recipient: person.account }, now))
245
+ link: "/work", source: { installation: true }, recipient: person.account }, now))
246
246
  queued++;
247
247
  });
248
248
  }
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);
@@ -18,8 +18,11 @@ export function deploymentGate(store, input) {
18
18
  if (run === null || ref === null || head === null)
19
19
  return refuse(`Run ${input.runId} is not a built candidate.`);
20
20
  // `task regate` reruns the approved check on the last built commit and records no-change: a candidate only for that commit.
21
- const regated = run.outcome === "no-change" && scope?.candidate === head &&
22
- store.runsFor(run.taskRef).find(one => one.id < input.runId && one.role === "builder" && one.outcome === "built")?.headRevision === head;
21
+ // A prepared candidate's build records a commit of its own holding exactly the candidate's tree; its rerun under the same
22
+ // signed scope is the same check of that commit (the digest is held to the approved one below).
23
+ const built = store.runsFor(run.taskRef).find(one => one.id < input.runId && one.role === "builder" && one.outcome === "built");
24
+ const regated = run.outcome === "no-change" && built?.headRevision === head &&
25
+ (scope?.candidate === head || ((scope?.candidate ?? null) !== null && built.scopeDigest === run.scopeDigest));
23
26
  if (run.role !== "builder" || !(run.outcome === "built" || regated))
24
27
  return refuse(`Run ${input.runId} is not a built candidate at ${head.slice(0, 7)}.`);
25
28
  if (scope === null || scope.approvedDigest !== scope.digest || scope.digest !== run.scopeDigest)
@@ -92,8 +92,12 @@ 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): {
100
+ version?: never;
97
101
  active: boolean;
98
102
  phase: string;
99
103
  detail: string;
@@ -107,7 +111,6 @@ export declare function desktopUpdateStatus(stateDir: string): {
107
111
  wasRunning?: never;
108
112
  backupPath?: never;
109
113
  updateId?: never;
110
- version?: never;
111
114
  buildId?: never;
112
115
  workDir?: never;
113
116
  } | {
@@ -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
  }
package/dist/dispose.js CHANGED
@@ -23,7 +23,7 @@ import { nightlyTaskRef, releaseTaskRef } from "./store.js";
23
23
  import {} from "./proof.js";
24
24
  import { verificationEvidence, failedVerificationEvidence } from "./verification-evidence.js";
25
25
  import { classifyGateFailure, describeGateFailure, gateFailureSummary } from "./gate-failure.js";
26
- import { propose, approve } from "./scope.js";
26
+ import { propose, approve, liveReleaseOf } from "./scope.js";
27
27
  import { headWithin } from "./names.js";
28
28
  /** The reasons tick classifies as the attempt itself breaking. */
29
29
  const TICK_FAILURE_REASONS = new Set([
@@ -456,6 +456,35 @@ export function regateTask(store, taskId, now, approval) {
456
456
  return { ok: false, reason: "no-attempt", message: `${taskId} has no scope` };
457
457
  const head = last.headRevision;
458
458
  const actor = approval.kind === "operator" ? approval.name : `mode ${approval.name}`;
459
+ // A prepared candidate (a release gate, the nightly check) already names the approved commit, and the builder loads
460
+ // exactly its tree on every attempt. The rerun keeps that scope and its approval as they are: nothing is proposed or
461
+ // sealed again, so a regate can never swap the approved commit, its release terms or the person's yes for others
462
+ // (2026-10-27: a regate rewrote release-0.9.73's candidate to its own build commit and the release refused it).
463
+ if ((scope.candidate ?? null) !== null || (scope.releaseTerms ?? null) !== null) {
464
+ const candidate = scope.candidate ?? null;
465
+ if (candidate === null || scope.approvedDigest !== scope.digest || last.scopeDigest !== scope.digest) {
466
+ return { ok: false, reason: "not-approved", message: `${taskId}'s approved commit or scope changed since attempt #${last.id}, or its approval no longer stands; approve its scope again instead` };
467
+ }
468
+ // A release's task runs again only while its one approval still stands for it (the dispatch would refuse it anyway).
469
+ const release = liveReleaseOf(store, taskId, "dispatch", now);
470
+ if (release.applies && !release.ok)
471
+ return { ok: false, reason: "not-approved", message: `${taskId} can't run again: ${release.why}` };
472
+ // Any other seal stands only while its authority does (an expired mode, a revoked approver): the queue would never
473
+ // dispatch it, so the regate says so instead of queueing a rerun that can't start.
474
+ if (!store.modeApprovalLive(ref.id, now))
475
+ return { ok: false, reason: "not-approved", message: `${taskId}'s approval no longer stands; approve its scope again instead` };
476
+ return store.transact(() => {
477
+ if (store.getTask(taskId)?.state !== "queued") {
478
+ const queued = store.requeueTask(taskId, actor, now);
479
+ if (!queued.ok) {
480
+ const why = { claimed: "claimed", "accepted-result": "accepted-result", published: "published" };
481
+ return { ok: false, reason: why[queued.reason] ?? "not-rejected", message: `${taskId} could not be queued again: ${queued.reason}` };
482
+ }
483
+ }
484
+ store.addRunNote(last.id, "Toolroll", `The check will run again on the approved commit ${candidate.slice(0, 7)}, under the same approval, as a new attempt, ${approval.kind === "operator" ? `on ${approval.name}'s say-so` : "automatically once, because the failure was outside this change"}.`, now);
485
+ return { ok: true, run: last.id, head: candidate };
486
+ });
487
+ }
459
488
  return store.transact(() => {
460
489
  const proposed = propose(store, {
461
490
  taskId, goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd,
@@ -520,7 +549,11 @@ export function maybeTriggerRepair(store, repo, evidenceRoot, sourceRunId, verdi
520
549
  // to a person. No repair draft, no third pass, on either road.
521
550
  {
522
551
  const rerunTask = store.refById(run.taskRef)?.externalId ?? "";
523
- if (run.headRevision && store.getScope(rerunTask)?.candidate === run.headRevision && store.builderAttemptsAt(run.taskRef, run.headRevision) >= 2) {
552
+ // A prepared candidate's attempts each record their own commit of the candidate's tree, so a rerun under the same
553
+ // approved scope is the same commit's check even when its head isn't the candidate itself.
554
+ const rerunScope = store.getScope(rerunTask);
555
+ const sameCommit = rerunScope !== null && (rerunScope.candidate === run.headRevision || ((rerunScope.candidate ?? null) !== null && rerunScope.digest === run.scopeDigest));
556
+ if (run.headRevision && sameCommit && store.builderAttemptsAt(run.taskRef, run.headRevision) >= 2) {
524
557
  const contradicted = cause === "review";
525
558
  store.enqueueNotification({ source: { run: sourceRunId }, dedupeKey: `regate-failed:${sourceRunId}`, kind: "repair-evidence", pushClass: "attention",
526
559
  subject: contradicted ? "The rerun on the same commit was contradicted by its review" : "The project check failed again on the same commit",