toolroll 0.9.72 → 0.9.74

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 (137) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/THIRD_PARTY_NOTICES.md +40 -0
  3. package/dist/agent-fence.d.ts +5 -0
  4. package/dist/agent-fence.js +42 -7
  5. package/dist/agent-turns.js +5 -0
  6. package/dist/agents-live.d.ts +31 -0
  7. package/dist/agents-live.js +43 -0
  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 +6 -0
  12. package/dist/builder.js +1 -1
  13. package/dist/chat-actions.js +13 -8
  14. package/dist/chat-continuity.js +10 -0
  15. package/dist/chat-decide.d.ts +47 -1
  16. package/dist/chat-decide.js +170 -4
  17. package/dist/chat-quiet.js +3 -3
  18. package/dist/check-report.js +2 -1
  19. package/dist/coding-provider.js +4 -2
  20. package/dist/commit-extract.d.ts +17 -0
  21. package/dist/commit-extract.js +82 -4
  22. package/dist/contracts/console-api.d.ts +3 -3
  23. package/dist/contracts/console-api.js +4 -4
  24. package/dist/contracts/release-terms.d.ts +7 -1
  25. package/dist/contracts/release-terms.js +6 -2
  26. package/dist/deploy-gate.js +5 -2
  27. package/dist/desktop-update.d.ts +1 -1
  28. package/dist/dispose.js +35 -2
  29. package/dist/exec.d.ts +1 -1
  30. package/dist/exec.js +20 -19
  31. package/dist/first-run.d.ts +17 -1
  32. package/dist/first-run.js +38 -0
  33. package/dist/flow-gallery-ui.js +4 -2
  34. package/dist/flow-gallery.d.ts +3 -0
  35. package/dist/flow-gallery.js +6 -2
  36. package/dist/flow-triggers.d.ts +8 -0
  37. package/dist/flow-triggers.js +15 -0
  38. package/dist/flows-cli.js +13 -3
  39. package/dist/guides.js +10 -1
  40. package/dist/invoke.d.ts +13 -0
  41. package/dist/invoke.js +21 -12
  42. package/dist/kits-ui.js +4 -2
  43. package/dist/kits.d.ts +6 -4
  44. package/dist/kits.js +9 -11
  45. package/dist/knowledge-ui.d.ts +4 -1
  46. package/dist/knowledge-ui.js +10 -5
  47. package/dist/lead-progress.d.ts +4 -1
  48. package/dist/lead-progress.js +3 -1
  49. package/dist/lead.js +4 -0
  50. package/dist/live-bus.d.ts +2 -0
  51. package/dist/live-bus.js +1 -0
  52. package/dist/live-children-read.d.ts +52 -0
  53. package/dist/live-children-read.js +333 -0
  54. package/dist/live-children.d.ts +73 -0
  55. package/dist/live-children.js +318 -0
  56. package/dist/live-detail.d.ts +4 -3
  57. package/dist/live-detail.js +9 -5
  58. package/dist/live-input.js +7 -8
  59. package/dist/live-main-read.d.ts +19 -0
  60. package/dist/live-main-read.js +91 -0
  61. package/dist/live-open.d.ts +28 -0
  62. package/dist/live-open.js +125 -0
  63. package/dist/live-records.d.ts +10 -1
  64. package/dist/live-records.js +16 -5
  65. package/dist/live.d.ts +54 -22
  66. package/dist/live.js +218 -69
  67. package/dist/mcp-connect.d.ts +10 -0
  68. package/dist/mcp-connect.js +2 -0
  69. package/dist/operate.js +3 -2
  70. package/dist/peek-cli.d.ts +10 -3
  71. package/dist/peek-cli.js +17 -8
  72. package/dist/planner.js +1 -1
  73. package/dist/project-tools.js +3 -1
  74. package/dist/protected-storage.d.ts +67 -0
  75. package/dist/protected-storage.js +398 -0
  76. package/dist/provider-limits.d.ts +3 -1
  77. package/dist/provider-limits.js +5 -2
  78. package/dist/provider.js +6 -12
  79. package/dist/release-adapters.d.ts +8 -1
  80. package/dist/release-adapters.js +84 -14
  81. package/dist/release-authority.d.ts +6 -0
  82. package/dist/release-authority.js +9 -0
  83. package/dist/release-card.d.ts +76 -0
  84. package/dist/release-card.js +252 -0
  85. package/dist/release-carry.d.ts +3 -0
  86. package/dist/release-carry.js +23 -4
  87. package/dist/release-cli.js +36 -3
  88. package/dist/release-git.d.ts +43 -0
  89. package/dist/release-git.js +106 -0
  90. package/dist/release.d.ts +11 -2
  91. package/dist/release.js +82 -6
  92. package/dist/repository-context-ui.js +2 -1
  93. package/dist/run-efficiency.d.ts +3 -3
  94. package/dist/run-efficiency.js +20 -8
  95. package/dist/scope.d.ts +7 -0
  96. package/dist/scope.js +22 -5
  97. package/dist/scout.d.ts +3 -0
  98. package/dist/scout.js +5 -3
  99. package/dist/serve.js +34 -23
  100. package/dist/server/agents-live-lead.d.ts +47 -0
  101. package/dist/server/agents-live-lead.js +116 -0
  102. package/dist/server/agents-live-room.d.ts +23 -0
  103. package/dist/server/agents-live-room.js +88 -0
  104. package/dist/server/chat.js +84 -30
  105. package/dist/server/chrome.js +2 -2
  106. package/dist/server/console.css +68 -40
  107. package/dist/server/flows.js +40 -10
  108. package/dist/server/live.d.ts +4 -0
  109. package/dist/server/live.js +42 -2
  110. package/dist/server/pages.js +81 -1
  111. package/dist/server/render-chat.d.ts +15 -1
  112. package/dist/server/render-chat.js +14 -7
  113. package/dist/server/render-pages.d.ts +8 -4
  114. package/dist/server/render-pages.js +55 -50
  115. package/dist/server/render-settings.js +1 -1
  116. package/dist/server/render-tasks.js +10 -3
  117. package/dist/server/route-table.js +5 -1
  118. package/dist/server/runtime.d.ts +1 -1
  119. package/dist/server/settings.js +29 -8
  120. package/dist/server/task-views.js +1 -1
  121. package/dist/server/tasks.js +59 -41
  122. package/dist/setup-guide.d.ts +2 -2
  123. package/dist/store.d.ts +34 -1
  124. package/dist/store.js +81 -2
  125. package/dist/stream-scope.d.ts +18 -0
  126. package/dist/stream-scope.js +28 -0
  127. package/dist/subagent-memory.d.ts +1 -0
  128. package/dist/subagent-memory.js +1 -1
  129. package/dist/subagent-tools.d.ts +22 -6
  130. package/dist/subagent-tools.js +44 -8
  131. package/dist/subagent-work.d.ts +2 -0
  132. package/dist/subagent-work.js +5 -2
  133. package/dist/subagents-ui.js +6 -4
  134. package/dist/subscription-chat.js +5 -3
  135. package/dist/telegram.d.ts +6 -1
  136. package/dist/telegram.js +58 -6
  137. package/package.json +1 -1
@@ -20,7 +20,7 @@ import { CHAT_TASK_ACTIONS } from "../chat-task-actions.js";
20
20
  import {} from "../contracts/console-api.js";
21
21
  import {} from "../dispatch.js";
22
22
  import {} from "../evidence.js";
23
- import { firstTaskJourney } from "../first-run.js";
23
+ import { taskRoad } from "../first-run.js";
24
24
  import { starterForWork } from "../flow-starters.js";
25
25
  import {} from "../gaps.js";
26
26
  import { readAuthModeStrict } from "../keys.js";
@@ -2359,10 +2359,17 @@ export function taskBodyParts(data) {
2359
2359
  { id: "task-diagnostics", title: "Task options", html: htmlString(optionsHtml), open: optionsOpen, count: null },
2360
2360
  ],
2361
2361
  cancel: cancelForm === "" ? null : { html: htmlString(cancelForm), open: data.cancelDraft !== undefined },
2362
- journey: data.guide !== true || task.state === "cancelled" || data.status === undefined ? null : (() => {
2362
+ // Every task's road (the first task's guide used to be the only one): facts only, so the checks step says when
2363
+ // checks didn't run, and a research report has none.
2364
+ journey: task.state === "cancelled" || data.status === undefined ? null : (() => {
2363
2365
  const planning = data.plan === "requested" && approveForm === "";
2364
2366
  const read = data.assignment != null ? assignmentStageOf(data.assignment, { token: data.status.token }, planning) : stageOfCode(data.status.token, { planning });
2365
- return firstTaskJourney(read, scope !== null && approvalOf(scope).approved, task.state === "done");
2367
+ // The checks fact the status card shows, read as what happened: a "note" is a failure when its words say so.
2368
+ const shown = statusCard?.status.details.find(one => one.key === "checks") ?? null;
2369
+ const checks = shown === null ? null : shown.mark === "ok" ? "passed" : shown.mark === "running" ? "running"
2370
+ : shown.mark === "failed" || /\bfailed\b/i.test(shown.text) ? "failed" : shown.mark === "none" ? "not-run" : "unread";
2371
+ const report = data.deliverable === "report" || data.report != null;
2372
+ return taskRoad(read, scope !== null && approvalOf(scope).approved, task.state === "done", { checks, report });
2366
2373
  })(),
2367
2374
  // "Do this every time…": the starter flow that does this kind of work on its own, one yes away.
2368
2375
  thread,
@@ -139,10 +139,14 @@ get("tasks", "done", "/done", { limited: "collection" });
139
139
  get("settings", "mode", "/mode");
140
140
  get("people", "people.page", "/people", { limited: "unscoped", ...free });
141
141
  get("pages", "system", "/system");
142
- get("tasks", "tasks", "/tasks", { limited: "collection" });
142
+ get("tasks", "tasks", "/tasks", { limited: "collection", ...free }); // a redirect to /work now: never sent to the project opener first
143
143
  get("tasks", "queue", "/queue");
144
144
  get("pages", "peek", "/peek", free);
145
145
  get("pages", "agents", "/agents", { limited: "unscoped", ...free });
146
+ // The Agents view's Live grid (docs/plans/agents-view.md §3): read-only, every request filtered to what the viewer may see.
147
+ // Browser sessions only, like the transcript and the peek: no token (a lead's, a runner's, a remote one) reads live output.
148
+ get("pages", "agents.live", "/agents/live", { limited: "unscoped", callers: ["cookie"], ...free });
149
+ get("pages", "agents.live.read", `^\\/agents\\/live\\/${ID}$`, { limited: "unscoped", callers: ["cookie"], ...free, sample: "/agents/live/1" });
146
150
  get("pages", "fleet", "/fleet", free);
147
151
  get("tasks", "tasks.new", "/tasks/new", { limited: "collection", ...free });
148
152
  get("tasks", "task.page", `^\\/t\\/${TASK}$`, { project: "task", limited: "resource", ...free, sample: "/t/one" });
@@ -146,7 +146,7 @@ export interface ServerRuntime {
146
146
  via: "cookie";
147
147
  }, principal: VerifiedApprover, enabled: Extract<ChatEnablement, {
148
148
  ok: true;
149
- }>, ceilingUsd: number, follow: boolean, now: Date) => number;
149
+ }>, ceilingUsd: number, follow: boolean, now: Date, scope?: LeadThreadScope, request?: string) => number;
150
150
  projectFamilyPeek: (repo: string, now: Date) => ProjectPeek;
151
151
  needsYouBadge: (project: string | null) => {
152
152
  count: number;
@@ -63,7 +63,7 @@ import { starterOf, startersFor, switchOnStarter } from "../flow-starters.js";
63
63
  import { disconnectGoogle, googleConnected, googleConsent, readGoogleMail, saveGoogleClient } from "../google-mail.js";
64
64
  import { integrationsHtml } from "../integrations-ui.js";
65
65
  import { clearProviderKey, keyStatus, plausibleKey, PROVIDER_KEY_ENV, readAuthMode, readProviderKey, saveProviderKey, setAuthMode, SUBSCRIPTION_CAPABLE, verdictWords, verifyProviderKey } from "../keys.js";
66
- import { decisionsHtml, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
66
+ import { decisionsHtml, framedSection, knowledgeHtml, MEMORY_INTRO, memorySearchHtml, proposalsHtml } from "../knowledge-ui.js";
67
67
  import { aboutYouOf, checkAboutYou, saveAboutYou } from "../lead-about.js";
68
68
  import { cancelCommitment, conditionWords, openCommitments } from '../lead-commitments.js';
69
69
  import { checkLeadIdentity, leadIdentityOf } from "../lead-identity.js";
@@ -508,7 +508,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
508
508
  const chosen = url.searchParams.get("repo") ?? project ?? projects[0] ?? "";
509
509
  if (chosen && (!projects.includes(chosen) || !visible(chosen)))
510
510
  return refuse(response, who, 403, "That project is outside your access.", "/settings/knowledge");
511
- const selector = projects.length > 1 ? html `<form class="knowledge" method="get" action="/settings/knowledge"><label>Project<select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>` : '';
511
+ const selector = projects.length > 1 ? framedSection('Project', html `<form class="field-row" method="get" action="/settings/knowledge"><label><span class="visually-hidden">Project</span><select name="repo">${projects.map(p => html `<option value="${p}"${p === chosen ? ' selected' : ''}>${p.split('/').at(-1) ?? p}</option>`)}</select></label><button>Show project</button></form>`) : '';
512
512
  let content = html `<p>Add a project to keep its knowledge here.</p>`;
513
513
  if (chosen) {
514
514
  try {
@@ -533,7 +533,7 @@ ${connectionHtml(asked, connection, task)}${command === null ? "" : html `<p>On
533
533
  }
534
534
  content = html `<p class="meta">${MEMORY_INTRO}</p>${memorySearchHtml(chosen, memoryQuery, hits)}${proposals}${(decisionsHtml(chosen, listDecisions(store, chosen, who.name, { limit: 50 }), csrf, who.role === 'approver'))}\
535
535
  ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf : '')}${knowledgeHtml(view, csrf, who.role === 'approver')}\
536
- <details class="knowledge"><summary>Learned lessons</summary>${(learningHtml(learningView(store, evidenceRoot, chosen, who.name), csrf, who.role === 'approver'))}</details>`;
536
+ <details class="knowledge frame"><summary class="frame-head">Learned lessons</summary><div class="frame-panel">${(learningHtml(learningView(store, evidenceRoot, chosen, who.name), csrf, who.role === 'approver'))}</div></details>`;
537
537
  }
538
538
  catch {
539
539
  content = html `<p class="problem" role="alert">Project knowledge is unavailable. Reload to retry.</p>`;
@@ -1422,7 +1422,12 @@ ${repositoryContextHtml(chosen, query, result, who.role === 'approver' ? csrf :
1422
1422
  for (const [key, visit] of connectVisits)
1423
1423
  if (visit.expires < Date.now())
1424
1424
  connectVisits.delete(key);
1425
- connectVisits.set(started.state, started.visit);
1425
+ // The password above proves one thing: this connection, by this account as it is now, and (from a kit whose subagent
1426
+ // is set up) letting that subagent use this tool in this project. The callback holds the visit to exactly that.
1427
+ const kitSet = kit === null || also !== null ? null : kitOf(kit);
1428
+ const installed = kitSet === null || !kitSet.tools.some(one => one.tool === service.id) ? null : kitInstalled(store, kitSet, target);
1429
+ connectVisits.set(started.state, { ...started.visit, generation: store.accountOf(who.name)?.generation ?? -1,
1430
+ grant: installed === null ? null : { subagent: installed.mate.id, tool: service.id, repo: target } });
1426
1431
  return goOutside(response, started.go, `Going to ${service.label} to sign in…`, { state: started.state, path: CONNECT_CALLBACK, secure: origin.startsWith("https:") });
1427
1432
  }
1428
1433
  async function toolsChange(ctx) {
@@ -2015,6 +2020,11 @@ ${postForm("/settings/chat-approval/save", html `\
2015
2020
  if (wanted !== "auto" && wanted !== "bypassPermissions") {
2016
2021
  return refuse(response, who, 400, "permissions must be Auto or Full access", "/settings");
2017
2022
  }
2023
+ // Widening every new task to Full access takes a password (chat-management.md §0a.3): an ordinary step-up, so a
2024
+ // signed-in session does under Passwordless. Back to Auto narrows, and needs none.
2025
+ if (wanted === "bypassPermissions" && store.permissionDefault().mode !== "bypassPermissions" && !authenticateApprover(who, body.get("password") ?? "").ok) {
2026
+ return redirect(response, `/settings?said=${encodeURIComponent("Enter your Toolroll password to start new tasks with Full access. Nothing changed.")}`);
2027
+ }
2018
2028
  store.setPermissionDefault(wanted, who.name, now);
2019
2029
  return redirect(response, `/settings?said=${encodeURIComponent(wanted === "bypassPermissions"
2020
2030
  ? "New tasks now start with Full access. Approved tasks keep their setting."
@@ -2413,7 +2423,14 @@ ${modeWords(terms)
2413
2423
  return done("/settings/tools", "problem", "That sign-in was started in another browser. Connect again from this one.");
2414
2424
  const visit = connectVisits.get(state);
2415
2425
  connectVisits.delete(state);
2416
- if (visit === undefined || visit.expires < Date.now() || store.accountOf(visit.by)?.role !== "approver")
2426
+ // The signer as at Connect: still an approver, not revoked, the same generation, still in the project. Checked here,
2427
+ // before the connection changes, and again at the grant, after every wait.
2428
+ const standing = () => {
2429
+ const account = visit === undefined ? null : store.accountOf(visit.by);
2430
+ return visit !== undefined && account !== null && account.role === "approver" && account.revokedAt === null && visit.generation !== undefined
2431
+ && account.generation === visit.generation && store.accountCanAccess(visit.by, visit.repo);
2432
+ };
2433
+ if (visit === undefined || visit.expires < Date.now() || !standing())
2417
2434
  return done("/settings/tools", "problem", "That sign-in expired. Connect again.");
2418
2435
  const service = oneClickOf(visit.service);
2419
2436
  const kit = visit.kit === null ? null : kitOf(visit.kit);
@@ -2430,13 +2447,17 @@ ${modeWords(terms)
2430
2447
  const now = clock();
2431
2448
  if (!adapterPolicy({ caller: "service", capability: "none" }).ok)
2432
2449
  return respond(response, 403, "text/plain", "Forbidden");
2433
- const finished = await finishConnect(store, visit, code, now, { fetcher: options.connectFetch ?? fetch, home: toolHome, omitEnv: ALL_CREDENTIAL_ENV });
2450
+ const finished = await finishConnect(store, visit, code, now, { fetcher: options.connectFetch ?? fetch, home: toolHome, omitEnv: ALL_CREDENTIAL_ENV, stillStanding: standing });
2434
2451
  if (!finished.ok)
2435
2452
  return done(back, "problem", finished.said);
2436
2453
  // From a kit: its teammate may use the tool now (reading freely, the rest after a person approves each call).
2437
2454
  const set = kit === null || !kit.tools.some(one => one.tool === service.id) ? null : kitInstalled(store, kit, visit.repo);
2438
- if (set !== null && store.subagentGrant(set.mate.id, service.id) === null) {
2439
- const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome });
2455
+ const bound = visit.grant ?? null;
2456
+ if (set !== null && bound !== null && bound.subagent === set.mate.id && bound.tool === service.id && bound.repo === visit.repo && set.mate.repo === visit.repo
2457
+ && store.subagentGrant(set.mate.id, service.id) === null && standing()) {
2458
+ // The password at Connect (toolsConnect) is this grant's proof, for exactly the grant the visit names, while the
2459
+ // signer still stands: re-checked after the tool's listing, in the step that saves the grant.
2460
+ const granted = await grantTool(store, set.mate, service.id, visit.by, now, { toolHome }, { widening: "proved", stillProved: standing });
2440
2461
  if (granted.ok)
2441
2462
  return done(back, "said", `${service.label} is connected to ${to}, and ${nameOf(set.mate)} can use it: reading freely, the rest after you approve each call.`);
2442
2463
  }
@@ -779,7 +779,7 @@ export function createTaskViews(runtime) {
779
779
  return runtime.sendScreen(response, status, screen(admittedFamily.root.title, html `<h1>${admittedFamily.root.title}</h1><p>${admittedFamily.current.state}</p><p class="problem" data-history-problem>${admittedFamily.problem}</p>`, { chrome: runtime.chromeFor(admittedFamily.root.repo, "tasks") }));
780
780
  const data = taskViewData(taskId, who, problem);
781
781
  if (data === null)
782
- return refuse(response, who, 404, "no such task", "/tasks");
782
+ return refuse(response, who, 404, "no such task", "/work");
783
783
  const family = runtime.familyOf(taskId);
784
784
  const presentedData = { ...data, rootId: family?.root.id ?? taskId, rootTitle: family?.root.title ?? data.task.title,
785
785
  ...(family === null || data.assignment != null ? {} : { history: familyHistory(family) }),
@@ -1,4 +1,4 @@
1
- import { releaseAuthorityOfTask, revokeRelease } from "../store.js";
1
+ import { releaseAuthorityOfTask, revokeRelease, runLiveCommands } from "../store.js";
2
2
  import { steerCapabilityOf } from "../steer-capabilities.js";
3
3
  import { canReveal, localCheckoutOf, revealInFinder } from "../checkout-proof.js";
4
4
  /** A form's way back to a flow card ("flow:<flow>:<card>"), read strictly: numbers only, never a path. */
@@ -64,7 +64,7 @@ import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
64
64
  import { requestContext } from "./request-context.js";
65
65
  import { boardTickScript, oneLineOf, QUEUE_VIEW, refuse, regionScript, screen, transcriptScript, whenTime } from "./chrome.js";
66
66
  import { chatReturnWithSaid, matchTaskPath, redirect, respond, reviewHref, safeChatReturn, safeReturn, SAFETY, taskChatHref, taskHref } from "./http.js";
67
- import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, tasksPage, workPage } from "./render-pages.js";
67
+ import { boardBody, boardWorkers, donePage, inboxFingerprints, inboxPage, newTaskPage, nextPage, parseInboxTab, QUEUE_FRONT, queueBody, queueScript, TASK_STATES, workPage } from "./render-pages.js";
68
68
  import { runEfficiencyOf } from "../run-efficiency.js";
69
69
  import { ACCEPT_ANYWAY_NEEDS_REASON, editorFileHref, proofBundleView, rankReviewQueue, RESULT_REFUSALS, REVIEW_QUEUE_CAP, reviewCockpitDetailParts, reviewCockpitPage, reviewPriorityOf, runFactsFragment, runOutcomeBadge, runPage, RUNS_PAGE, runsPage, withRefusal } from "./render-results.js";
70
70
  import { approvalFormDigest, approveRefusalWords, checkProgressHtml, consentDoorOf, decisionPage, nonceHashOf, PermissionsWouldChange, requirementsFromEditor, resumeDigestOf, taskOf } from "./render-tasks.js";
@@ -207,12 +207,18 @@ export function createTasksHandlers(runtime) {
207
207
  // up like the inbox: admission binds the query and every row's repo
208
208
  // is re-proved here.
209
209
  const view = parseWorkView(url.searchParams.get("view"));
210
+ // The stored-state filter the old task list had (`?state=`): an unknown state is refused, as it was there.
211
+ const wantedState = url.searchParams.get("state");
212
+ if (wantedState !== null && !TASK_STATES.includes(wantedState))
213
+ return refuse(response, who, 400, "no such state", "/work");
214
+ const state = wantedState;
210
215
  const rollup = project === null && !unscopedMode;
211
216
  let work;
212
217
  try {
213
- work = workIndexPage(store, now, workAccess(), { view, limit: 40, cursor: url.searchParams.get('cursor'), project, root: evidenceRoot });
218
+ work = workIndexPage(store, now, workAccess(), { view, limit: 40, cursor: url.searchParams.get('cursor'), project, root: evidenceRoot, ...(state === null ? {} : { state }) });
214
219
  const facts = requestContext.getStore();
215
- if (facts !== undefined) {
220
+ // A filtered page never stands in for the counts or the Crew: those read the unfiltered work.
221
+ if (facts !== undefined && state === null) {
216
222
  // These counts cover the exact admitted project lens, including
217
223
  // unplaced tasks where permitted. Crew excludes unplaced rows.
218
224
  if (project === null)
@@ -259,6 +265,7 @@ export function createTasksHandlers(runtime) {
259
265
  previous: url.searchParams.has('cursor'),
260
266
  multiProject: new Set(work.items.map(task => task.repo ?? "")).size > 1,
261
267
  now,
268
+ state,
262
269
  // v105: subscription windows and monthly budgets, for whoever runs the installation.
263
270
  limits: store.isInstanceOperator(who.name)
264
271
  ? limitsView(store.providerLimits(), store.budgets().length === 0 ? [] : store.monthSpendCached(now).budgets, { project: projectName, subagent: id => subagentNamesOf(store.handle).get(id) ?? `Subagent ${id}` }, now)
@@ -565,24 +572,18 @@ export function createTasksHandlers(runtime) {
565
572
  const { response, project } = ctx;
566
573
  return sendScreen(response, 200, donePage(chromeFor(project, "runs"), store.listCompletedWorkScoped(project, 50), pr => store.ciFailureObserved(pr)));
567
574
  }
575
+ /** The old task list, retired into /work (docs/plans/reui-polish.md slice 7): its address keeps answering, as a
576
+ * redirect, so links and bookmarks still land. `state` carries over (an unknown one is still refused); `template`
577
+ * goes to the add page, which now pre-fills it. Anything else is dropped. */
568
578
  async function showTasks(ctx) {
569
- const { url, who, response, project } = ctx;
579
+ const { url, who, response } = ctx;
570
580
  const wanted = url.searchParams.get("state");
571
- if (wanted !== null && !TASK_STATES.includes(wanted)) {
572
- return refuse(response, who, 400, "no such state", "/tasks");
573
- }
574
- // ?template=<name> pre-fills the add form from the shipped library —
575
- // a pre-filled form and nothing more; the submission path is the
576
- // same guarded handler either way.
577
- const fromTemplate = url.searchParams.get("template");
578
- const picked = fromTemplate === null ? null : templateByName(fromTemplate);
579
- const prefill = picked !== null && picked.kind === "task"
580
- ? {
581
- title: picked.title, goal: picked.goal, not: picked.outOfScope ?? "", touches: picked.touches.join(", "),
582
- acceptance: acceptanceToLines(picked.acceptance).join("\n"),
583
- }
584
- : null;
585
- return sendScreen(response, 200, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project, wanted === null ? undefined : wanted).slice(0, 200), wanted, null, project, prefill, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
581
+ if (wanted !== null && !TASK_STATES.includes(wanted))
582
+ return refuse(response, who, 400, "no such state", "/work");
583
+ const template = url.searchParams.get("template");
584
+ if (template !== null)
585
+ return redirect(response, `/tasks/new?template=${encodeURIComponent(template)}`);
586
+ return redirect(response, wanted === null ? "/work" : `/work?state=${encodeURIComponent(wanted)}`);
586
587
  }
587
588
  async function showQueue(ctx) {
588
589
  const { url, who, response, project } = ctx;
@@ -599,14 +600,11 @@ export function createTasksHandlers(runtime) {
599
600
  return redirect(response, QUEUE_VIEW);
600
601
  }
601
602
  async function showNewTask(ctx) {
602
- const { who, response, project } = ctx;
603
- const revision = who.via === "cookie" ? who.session.projectRevision : 0;
604
- const chainable = store
605
- .listTasksScoped(project, undefined, 100, null)
606
- .filter(one => one.state !== "done" && one.state !== "cancelled" && visible(one.repo))
607
- .map(one => ({ id: one.id, title: one.title }));
608
- const chrome = chromeFor(project, "tasks");
609
- return sendScreen(response, 200, newTaskPage(chrome, project, revision, null, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? []));
603
+ const picked = (() => { const name = ctx.url.searchParams.get("template"); return name === null ? null : templateByName(name); })();
604
+ const prefill = picked !== null && picked.kind === "task"
605
+ ? { title: picked.title, goal: picked.goal, not: picked.outOfScope ?? "", touches: picked.touches.join(", "), acceptance: acceptanceToLines(picked.acceptance).join("\n") }
606
+ : null;
607
+ return newTaskScreen(ctx, 200, null, prefill);
610
608
  }
611
609
  async function showTask(ctx) {
612
610
  const { url, who, response } = ctx;
@@ -615,7 +613,7 @@ export function createTasksHandlers(runtime) {
615
613
  return noPage(who, response);
616
614
  const family = familyOf(task.taskId);
617
615
  if (family === null)
618
- return refuse(response, who, 404, "no such task", "/tasks");
616
+ return refuse(response, who, 404, "no such task", "/work");
619
617
  const version = url.searchParams.get("version");
620
618
  if (version !== null && !family.versions.some(one => one.id === version))
621
619
  return refuse(response, who, 404, "That version is not available for this task.", taskHref(family.root.id));
@@ -666,12 +664,12 @@ export function createTasksHandlers(runtime) {
666
664
  const access = workAccess();
667
665
  const family = store.taskFamilyOf(evidence.taskId, access.repos, access.includeUnplaced);
668
666
  if (family === null || !visible(family.root.repo))
669
- return refuse(response, who, 404, "no such task", "/tasks");
667
+ return refuse(response, who, 404, "no such task", "/work");
670
668
  if (evidence.taskId !== family.root.id)
671
669
  return redirect(response, `${taskHref(family.root.id)}/evidence${url.search}`);
672
670
  const pack = evidencePack(store, family.root.id, access, who.name, now, evidenceRoot);
673
671
  if (pack === null)
674
- return refuse(response, who, 404, "no such task", "/tasks");
672
+ return refuse(response, who, 404, "no such task", "/work");
675
673
  if (url.searchParams.get("format") === "json") {
676
674
  response.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="evidence-${family.root.id.replace(/[^A-Za-z0-9._-]/g, "_")}.json"`,
677
675
  "cache-control": "no-store", "x-content-type-options": "nosniff" });
@@ -782,11 +780,13 @@ export function createTasksHandlers(runtime) {
782
780
  const tailRaw = url.searchParams.get("ktail") ?? "";
783
781
  const keys = { inKey: url.searchParams.get("inkey") === "1", tail: validKeyTail(tailRaw) ? tailRaw : "" };
784
782
  if (records) {
785
- const shown = liveWindowRecords(window, keys);
783
+ // Command text and output only under this run's own captured answer, never the file's `open` record (v136).
784
+ const commands = runLiveCommands(store, found.id);
785
+ const shown = liveWindowRecords(window, commands, keys);
786
786
  return respond(response, 200, "application/json", JSON.stringify({ records: shown, nextOffset: window.nextOffset, gen: window.gen, inKey: keys.inKey, keyTail: keys.tail ?? "", final, ...notesOf() }));
787
787
  }
788
788
  // The plain words, whatever the file's format; the offset moves by the bytes read even when nothing shows.
789
- const lines = liveWindowLines(window, keys);
789
+ const lines = liveWindowLines(window, runLiveCommands(store, found.id), keys);
790
790
  return respond(response, 200, "application/json", JSON.stringify({ text: lines.length === 0 ? "" : `${lines.join("\n")}\n`, nextOffset: window.nextOffset, gen: window.gen, inKey: keys.inKey, keyTail: keys.tail ?? "", final }));
791
791
  }
792
792
  // One result page (2026-10-02): a builder's finished result opens on
@@ -978,7 +978,7 @@ export function createTasksHandlers(runtime) {
978
978
  if (who.via === "cookie") {
979
979
  const seen = body.get("projectRevision");
980
980
  if (seen !== null && seen !== String(who.session.projectRevision)) {
981
- return refuse(response, who, 409, "the open project changed since this form was rendered — reload and try again", "/tasks");
981
+ return refuse(response, who, 409, "the open project changed since this form was rendered — reload and try again", "/tasks/new");
982
982
  }
983
983
  }
984
984
  const id = (body.get("id") ?? "").trim();
@@ -998,8 +998,10 @@ export function createTasksHandlers(runtime) {
998
998
  // finding 1): the form's `required` is a courtesy, not the guard — a
999
999
  // direct POST with no project open must not mint an unplaced task
1000
1000
  // under a ceiling. Unscoped mode keeps its historic unplaced filings.
1001
+ // A refusal from here on is the add page again with the person's own words (the old task list kept them too).
1002
+ const draft = { title, goal: body.get("goal") ?? "", not: body.get("not") ?? "", touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent };
1001
1003
  if (!unscopedMode && effective === "") {
1002
- return sendScreen(response, 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, "name a repository — no project is open, so the task must say where it belongs", project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
1004
+ return newTaskScreen(ctx, 400, "name a repository — no project is open, so the task must say where it belongs", draft);
1003
1005
  }
1004
1006
  let repo = effective;
1005
1007
  let admitted = unscopedMode ? null : admissionList() ?? [];
@@ -1007,7 +1009,7 @@ export function createTasksHandlers(runtime) {
1007
1009
  if (rootMode && effective !== "") {
1008
1010
  const canonical = (await authorizedProject(liveCeiling(), effective)) ? canonicalProject(effective) : null;
1009
1011
  if (canonical === null || canonical === undefined) {
1010
- return sendScreen(response, 403, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, `${effective} is outside what this server was configured to show`, project, null, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
1012
+ return newTaskScreen(ctx, 403, `${effective} is outside what this server was configured to show`, draft);
1011
1013
  }
1012
1014
  repo = canonical;
1013
1015
  admitted = [canonical];
@@ -1063,7 +1065,7 @@ export function createTasksHandlers(runtime) {
1063
1065
  return filed;
1064
1066
  });
1065
1067
  if (!made.ok) {
1066
- return sendScreen(response, made.reason === "backlog-full" ? 429 : 400, tasksPage(chromeFor(project, "tasks"), familyTasksInView(project).slice(0, 200), null, made.message, project, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent }, store.permissionDefault().mode, store.qualityDefault().mode, store.replacements()));
1068
+ return newTaskScreen(ctx, made.reason === "backlog-full" ? 429 : 400, made.message, { title, goal, not: notThis, touches: body.get("touches") ?? "", acceptance: body.get("acceptance") ?? "", values: body.sent });
1067
1069
  }
1068
1070
  const actionContext = requestContext.getStore();
1069
1071
  if (actionContext !== undefined)
@@ -1248,7 +1250,7 @@ export function createTasksHandlers(runtime) {
1248
1250
  const back = taskHref(act.taskId);
1249
1251
  const ref = store.lookupRef(act.taskId);
1250
1252
  if (ref === null || !visible(ref.repo))
1251
- return refuse(response, who, 404, "no such task", "/tasks");
1253
+ return refuse(response, who, 404, "no such task", "/work");
1252
1254
  const named = (body.get("run") ?? "").trim();
1253
1255
  const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
1254
1256
  const runTask = run === null ? null : store.externalIdFor(run.taskRef);
@@ -1283,7 +1285,7 @@ export function createTasksHandlers(runtime) {
1283
1285
  return taskScreen(response, who, act.taskId, "The demo merges nothing.", 403);
1284
1286
  const ref = store.lookupRef(act.taskId);
1285
1287
  if (ref === null || !visible(ref.repo))
1286
- return refuse(response, who, 404, "no such task", "/tasks");
1288
+ return refuse(response, who, 404, "no such task", "/work");
1287
1289
  const named = (body.get("run") ?? "").trim();
1288
1290
  const run = /^[0-9]{1,15}$/.test(named) ? store.getRun(Number(named)) : null;
1289
1291
  const runTask = run === null ? null : store.externalIdFor(run.taskRef);
@@ -1733,10 +1735,10 @@ export function createTasksHandlers(runtime) {
1733
1735
  function taskMutation(response, who, taskId, verb, posted, now) {
1734
1736
  const ref = store.lookupRef(taskId);
1735
1737
  if (ref === null || store.getTask(taskId) === null) {
1736
- return refuse(response, who, 404, "no such task", "/tasks");
1738
+ return refuse(response, who, 404, "no such task", "/work");
1737
1739
  }
1738
1740
  if (!visible(ref.repo)) {
1739
- return refuse(response, who, 404, "no such task", "/tasks");
1741
+ return refuse(response, who, 404, "no such task", "/work");
1740
1742
  }
1741
1743
  switch (verb) {
1742
1744
  case "steer": {
@@ -3065,5 +3067,21 @@ export function createTasksHandlers(runtime) {
3065
3067
  "session.editor-links": sessionEditorLinks,
3066
3068
  "incident.resolve": resolveIncident,
3067
3069
  });
3070
+ /** The add page, once more: blank, from a shipped template (`?template=`, as the old task list did), or with a refused
3071
+ * filing's own words and status (the draft keeps the project revision it was written against). */
3072
+ function newTaskScreen(ctx, status, problem, draft) {
3073
+ const { who, response, project } = ctx;
3074
+ const chainable = store
3075
+ .listTasksScoped(project, undefined, 100, null)
3076
+ .filter(one => one.state !== "done" && one.state !== "cancelled" && visible(one.repo))
3077
+ .map(one => ({ id: one.id, title: one.title }));
3078
+ // A refused draft keeps the task it was to start after, even if the refreshed list no longer carries it.
3079
+ const after = draft?.values?.get("after") ?? "";
3080
+ if (after !== "" && !chainable.some(one => one.id === after))
3081
+ chainable.unshift({ id: after, title: "(as submitted)" });
3082
+ const chrome = chromeFor(project, "tasks");
3083
+ const revision = draft?.values !== undefined ? undefined : who.via === "cookie" ? who.session.projectRevision : 0;
3084
+ return sendScreen(response, status, newTaskPage(chrome, project, revision, problem, chainable, store.permissionDefault().mode, store.qualityDefault().mode, chrome.projects ?? [], draft));
3085
+ }
3068
3086
  return { registrations, runListPane, peekFragment, taskMutation, queueRegionFor, ciFailingFor, completedRowFor, attemptRowFor, resultRowOf, reviewCockpitViewOf, decisionEvidence, runEvidence, sendArtifact, peekGuards, liveRunIds };
3069
3087
  }
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
+ content?: never;
18
19
  ok: false;
19
20
  message: string;
20
21
  plan?: never;
21
- content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
+ content?: never;
33
34
  ok: false;
34
35
  message: string;
35
36
  plan?: never;
36
- content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
package/dist/store.d.ts CHANGED
@@ -103,6 +103,9 @@ export declare const BRANCH_BASE_MOVE_SCHEMA = "\nCREATE TABLE IF NOT EXISTS bra
103
103
  * continues a paused build with a short continue frame and no note: one row per request, bound to the exact paused run;
104
104
  * sent_at when the frame was written, ended_at when the run was no longer paused to take it. */
105
105
  export declare const LIVE_STEER_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_pause (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n request_id TEXT NOT NULL UNIQUE,\n requested_by TEXT NOT NULL,\n requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),\n requested_at TEXT NOT NULL,\n note INTEGER REFERENCES task_steer(id) ON DELETE SET NULL,\n sent_at TEXT,\n paused_at TEXT,\n resumed_at TEXT,\n ended_at TEXT\n);\nCREATE INDEX IF NOT EXISTS run_pause_by_run ON run_pause (run, id);\nCREATE TABLE IF NOT EXISTS run_resume (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,\n request_id TEXT NOT NULL UNIQUE,\n requested_by TEXT NOT NULL,\n requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),\n requested_at TEXT NOT NULL,\n sent_at TEXT,\n ended_at TEXT\n);\nCREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);\n";
106
+ /** v136: a run's own "Show commands in the live view" answer, captured once before its live file opens (live-detail.ts).
107
+ * Absent, unreadable or anything but 1 means off. */
108
+ export declare const RUN_LIVE_DETAIL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS run_live_detail (\n run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,\n commands INTEGER NOT NULL CHECK (commands IN (0,1)),\n captured_at TEXT NOT NULL\n);\n-- The Agents view's Live grid (docs/plans/agents-view.md \u00A72.5): every role's running runs and its recently ended ones.\n-- (id > 0 keeps them to the grid's own query, which names it: no other query's plan is offered them.)\nCREATE INDEX IF NOT EXISTS run_live_open ON run (id) WHERE outcome IS NULL AND id > 0;\nCREATE INDEX IF NOT EXISTS run_live_recent ON run (finished_at) WHERE outcome IS NOT NULL AND id > 0;\n";
106
109
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
107
110
  export declare const LIVE_DETAIL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS live_detail_setting (\n repo TEXT PRIMARY KEY,\n commands INTEGER NOT NULL CHECK (commands IN (0,1)),\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\n-- Every run a steering note reached, kept as it happens (live-notes.ts): a\n-- note's own attached/mailed fields move to the next attempt, this does not.\nCREATE TABLE IF NOT EXISTS steer_note_run (\n note INTEGER NOT NULL REFERENCES task_steer(id) ON DELETE CASCADE,\n run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,\n how TEXT NOT NULL CHECK (how IN ('attached','mailed','read')),\n at TEXT NOT NULL,\n PRIMARY KEY (note, run, how)\n);\nCREATE INDEX IF NOT EXISTS steer_note_run_by_run ON steer_note_run (run, note);\n";
108
111
  export declare const CHAT_APPROVAL_SCHEMA = "\nCREATE TABLE IF NOT EXISTS chat_approval_setting (\n approver TEXT NOT NULL,\n scope TEXT NOT NULL,\n enabled INTEGER NOT NULL CHECK (enabled IN (0,1)),\n full_access INTEGER NOT NULL DEFAULT 0 CHECK (full_access IN (0,1)),\n attempt_cap_microusd INTEGER CHECK (attempt_cap_microusd IS NULL OR attempt_cap_microusd >= 0),\n generation INTEGER NOT NULL,\n digest TEXT NOT NULL,\n updated_by TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (approver, scope)\n);\n";
@@ -183,7 +186,7 @@ export type SealedLedgerEntry = LedgerEntry & {
183
186
  hash: string;
184
187
  } | null;
185
188
  };
186
- export declare const SCHEMA_VERSION = 135;
189
+ export declare const SCHEMA_VERSION = 136;
187
190
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
188
191
  export declare const V118_MERGED_TABLES: Readonly<Record<string, CredentialKind>>;
189
192
  /**
@@ -2354,6 +2357,9 @@ export declare class Store {
2354
2357
  sealScopeApproval(taskId: string, by: string, now: Date, mutation?: Mutation, basis?: {
2355
2358
  kind: "mode" | "chat" | "grant" | "schedule";
2356
2359
  modeDigest: string;
2360
+ /** A chat seal of a deploy gate: the release card it came from (chat-management.md §0a.5). */
2361
+ card?: "release";
2362
+ cardDigest?: string;
2357
2363
  }, approverKind?: ApproverKind): boolean;
2358
2364
  /** Whether this task's scope carries a LIVE seal: approved, and the
2359
2365
  * approved digest is the current one (a rewritten scope unseals). The
@@ -7132,6 +7138,11 @@ export declare class Store {
7132
7138
  error: string;
7133
7139
  }, now: Date): boolean;
7134
7140
  private bumpPushFailures;
7141
+ /** Which conversation a page's first send started (quiet page visits): recorded with the start, in the same
7142
+ * transaction, so a retry of that send never starts another, even after a restart or once that one has ended. */
7143
+ leadStartReceipt(approver: string, request: string): {
7144
+ session: number;
7145
+ } | null;
7135
7146
  /** A chat send receipt contains no message text. The session is part of
7136
7147
  * its namespace; callers must prove that session before reading it. */
7137
7148
  leadRequestReceipt(session: number, request: string): {
@@ -7744,3 +7755,25 @@ export declare function currentReleaseIssuer(): ReleaseIssuer;
7744
7755
  * that can't be read — is not proof, and the operation stays for a person.
7745
7756
  */
7746
7757
  export declare function releaseIssuerGone(op: ReleaseOperation): boolean;
7758
+ /**
7759
+ * Capture a run's live-view answer once, before its live file opens: the first capture stands (a second never changes
7760
+ * it). Returns the answer now saved for the run; any failure, or a saved row that isn't plainly 1, is commands off.
7761
+ */
7762
+ export declare function captureRunLiveDetail(store: Store, runId: number, commands: boolean, now: Date): boolean;
7763
+ /** Whether a run's live view may show commands: only its own captured answer, and only when that is plainly 1. Absent
7764
+ * (a run from before v136, or one whose capture failed), unreadable or anything else is off. */
7765
+ export declare function runLiveCommands(store: Store, runId: number): boolean;
7766
+ /** One run as the Agents view's Live grid reads it (docs/plans/agents-view.md §2.5). */
7767
+ export type AgentsLiveRun = Run & {
7768
+ taskId: string;
7769
+ title: string;
7770
+ repo: string | null;
7771
+ builtIn: boolean;
7772
+ };
7773
+ /**
7774
+ * The Live grid's runs: every role's running run (no outcome yet) and every run that ended at or after `endedSince`,
7775
+ * newest first — all of them (no cutoff before the caller's visibility filter: a viewer's agent is never hidden behind
7776
+ * others'). Two indexed reads (run_live_open, run_live_recent), never a scan of all runs.
7777
+ * Visibility is the caller's: it filters each by the console's ceiling and the viewer's access.
7778
+ */
7779
+ export declare function agentsLiveRuns(store: Store, endedSince: Date): AgentsLiveRun[];
package/dist/store.js CHANGED
@@ -443,6 +443,19 @@ CREATE TABLE IF NOT EXISTS run_resume (
443
443
  );
444
444
  CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
445
445
  `;
446
+ /** v136: a run's own "Show commands in the live view" answer, captured once before its live file opens (live-detail.ts).
447
+ * Absent, unreadable or anything but 1 means off. */
448
+ export const RUN_LIVE_DETAIL_SCHEMA = `
449
+ CREATE TABLE IF NOT EXISTS run_live_detail (
450
+ run INTEGER PRIMARY KEY REFERENCES run(id) ON DELETE CASCADE,
451
+ commands INTEGER NOT NULL CHECK (commands IN (0,1)),
452
+ captured_at TEXT NOT NULL
453
+ );
454
+ -- The Agents view's Live grid (docs/plans/agents-view.md §2.5): every role's running runs and its recently ended ones.
455
+ -- (id > 0 keeps them to the grid's own query, which names it: no other query's plan is offered them.)
456
+ CREATE INDEX IF NOT EXISTS run_live_open ON run (id) WHERE outcome IS NULL AND id > 0;
457
+ CREATE INDEX IF NOT EXISTS run_live_recent ON run (finished_at) WHERE outcome IS NOT NULL AND id > 0;
458
+ `;
446
459
  /** A project's "Show commands in the live view" (live-detail.ts): absent means off. */
447
460
  export const LIVE_DETAIL_SCHEMA = `
448
461
  CREATE TABLE IF NOT EXISTS live_detail_setting (
@@ -737,7 +750,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
737
750
  // release_operation), each release check attempt's intended mode, sealed before it runs (release_check_attempt), the
738
751
  // nightly full check on main and its open failures (nightly_check, main_check_failure), and a scope's release terms
739
752
  // (task_scope.release_terms_json; saved rows read NULL: no release terms).
740
- export const SCHEMA_VERSION = 135;
753
+ // v136 adds, and changes no saved row: each run's own "Show commands in the live view" answer, captured once before its
754
+ // live file opens (run_live_detail; docs/plans/agents-view.md §2.4). Saved runs have none, which every reader takes as
755
+ // commands off: permission is never recovered from a transcript or the project's current setting.
756
+ export const SCHEMA_VERSION = 136;
741
757
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
742
758
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
743
759
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -870,7 +886,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
870
886
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
871
887
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
872
888
  */
873
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135]);
889
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136]);
874
890
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
875
891
  export const V119_ADDED_COLUMNS = Object.freeze({
876
892
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -4219,6 +4235,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4219
4235
  for (const [column, { definition }] of Object.entries(columns))
4220
4236
  addColumn(db, table, column, definition);
4221
4237
  }
4238
+ // v136: each run's own live-view answer (a new table only; saved runs have none: commands off).
4239
+ db.exec(RUN_LIVE_DETAIL_SCHEMA);
4222
4240
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4223
4241
  // nothing to reclaim.
4224
4242
  if (!fresh)
@@ -6989,6 +7007,12 @@ export class Store {
6989
7007
  const terms = storedReleaseTerms(working["release_terms_json"]);
6990
7008
  if (!terms.ok)
6991
7009
  return false;
7010
+ // A DEPLOY GATE FROM CHAT (chat-management.md §0a.5): a signed candidate or release terms seal from chat only
7011
+ // with the release card's evidence (its act and card digest), never through the plain plan card or a generic
7012
+ // one. Read from the raw row, so a terms column that doesn't parse is a deploy gate too.
7013
+ const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
7014
+ if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
7015
+ return false;
6992
7016
  if (terms.terms !== null && basis !== undefined && basis.kind !== "chat")
6993
7017
  return false;
6994
7018
  if (terms.terms !== null && releaseSealProblem(this, terms.terms) !== null)
@@ -19151,6 +19175,13 @@ export class Store {
19151
19175
  .run(pairId);
19152
19176
  }
19153
19177
  // ---- idempotency --------------------------------------------------------
19178
+ /** Which conversation a page's first send started (quiet page visits): recorded with the start, in the same
19179
+ * transaction, so a retry of that send never starts another, even after a restart or once that one has ended. */
19180
+ leadStartReceipt(approver, request) {
19181
+ const row = this.db.prepare("SELECT result FROM mutation WHERE idempotency_key = ? AND operation = 'mate-start'")
19182
+ .get(`mate-start:${approver}:${request}`);
19183
+ return row === undefined ? null : parseStoreColumn("mutation.result", row["result"]);
19184
+ }
19154
19185
  /** A chat send receipt contains no message text. The session is part of
19155
19186
  * its namespace; callers must prove that session before reading it. */
19156
19187
  leadRequestReceipt(session, request) {
@@ -20740,3 +20771,51 @@ function processStartedAt(pid) {
20740
20771
  return null;
20741
20772
  }
20742
20773
  }
20774
+ // A run's own "Show commands in the live view" answer (schema v136, docs/plans/agents-view.md §2.4). Free functions over
20775
+ // the store, kept together at the end.
20776
+ /**
20777
+ * Capture a run's live-view answer once, before its live file opens: the first capture stands (a second never changes
20778
+ * it). Returns the answer now saved for the run; any failure, or a saved row that isn't plainly 1, is commands off.
20779
+ */
20780
+ export function captureRunLiveDetail(store, runId, commands, now) {
20781
+ try {
20782
+ store.handle.prepare("INSERT OR IGNORE INTO run_live_detail (run, commands, captured_at) VALUES (?, ?, ?)").run(runId, commands ? 1 : 0, now.toISOString());
20783
+ }
20784
+ catch {
20785
+ return false;
20786
+ }
20787
+ return runLiveCommands(store, runId);
20788
+ }
20789
+ /** Whether a run's live view may show commands: only its own captured answer, and only when that is plainly 1. Absent
20790
+ * (a run from before v136, or one whose capture failed), unreadable or anything else is off. */
20791
+ export function runLiveCommands(store, runId) {
20792
+ try {
20793
+ const row = store.handle.prepare("SELECT commands FROM run_live_detail WHERE run = ?").get(runId);
20794
+ return row !== undefined && row["commands"] === 1;
20795
+ }
20796
+ catch {
20797
+ return false;
20798
+ }
20799
+ }
20800
+ /**
20801
+ * The Live grid's runs: every role's running run (no outcome yet) and every run that ended at or after `endedSince`,
20802
+ * newest first — all of them (no cutoff before the caller's visibility filter: a viewer's agent is never hidden behind
20803
+ * others'). Two indexed reads (run_live_open, run_live_recent), never a scan of all runs.
20804
+ * Visibility is the caller's: it filters each by the console's ceiling and the viewer's access.
20805
+ */
20806
+ export function agentsLiveRuns(store, endedSince) {
20807
+ const rows = store.handle.prepare(`SELECT run.*, task_ref.external_id AS live_task_id, task_ref.repo AS live_task_repo, task.title AS live_task_title, task_ref.backend AS live_task_backend
20808
+ FROM (SELECT id FROM run INDEXED BY run_live_open WHERE outcome IS NULL AND id > 0
20809
+ UNION SELECT id FROM run INDEXED BY run_live_recent WHERE outcome IS NOT NULL AND id > 0 AND finished_at >= ?) AS picked
20810
+ JOIN run ON run.id = picked.id
20811
+ JOIN task_ref ON task_ref.id = run.task_ref
20812
+ LEFT JOIN task ON task_ref.backend = ? AND task.id = task_ref.external_id
20813
+ ORDER BY run.id DESC`).all(endedSince.toISOString(), BUILT_IN);
20814
+ return rows.map(row => ({
20815
+ ...readRun(row),
20816
+ taskId: String(row["live_task_id"]),
20817
+ builtIn: row["live_task_backend"] === BUILT_IN,
20818
+ title: row["live_task_title"] === null || row["live_task_title"] === undefined ? String(row["live_task_id"]) : String(row["live_task_title"]),
20819
+ repo: row["live_task_repo"] === null || row["live_task_repo"] === undefined ? null : String(row["live_task_repo"]),
20820
+ }));
20821
+ }