toolroll 0.9.73 → 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 (84) hide show
  1. package/CHANGELOG.md +32 -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/browser/THIRD_PARTY_NOTICES.txt +40 -0
  7. package/dist/browser/workspace.css +2 -2
  8. package/dist/browser/workspace.js +31 -30
  9. package/dist/browser-workspace.d.ts +6 -0
  10. package/dist/builder.js +1 -1
  11. package/dist/chat-continuity.js +10 -0
  12. package/dist/chat-quiet.js +3 -3
  13. package/dist/deploy-gate.js +5 -2
  14. package/dist/desktop-update.d.ts +1 -1
  15. package/dist/dispose.js +35 -2
  16. package/dist/exec.js +13 -16
  17. package/dist/first-run.d.ts +17 -1
  18. package/dist/first-run.js +38 -0
  19. package/dist/invoke.d.ts +13 -0
  20. package/dist/invoke.js +21 -12
  21. package/dist/knowledge-ui.d.ts +4 -1
  22. package/dist/knowledge-ui.js +10 -5
  23. package/dist/lead-progress.d.ts +4 -1
  24. package/dist/lead-progress.js +3 -1
  25. package/dist/lead.js +4 -0
  26. package/dist/live-bus.d.ts +2 -0
  27. package/dist/live-bus.js +1 -0
  28. package/dist/live-children-read.d.ts +52 -0
  29. package/dist/live-children-read.js +333 -0
  30. package/dist/live-children.d.ts +73 -0
  31. package/dist/live-children.js +318 -0
  32. package/dist/live-detail.d.ts +4 -3
  33. package/dist/live-detail.js +9 -5
  34. package/dist/live-input.js +7 -8
  35. package/dist/live-main-read.d.ts +19 -0
  36. package/dist/live-main-read.js +91 -0
  37. package/dist/live-open.d.ts +28 -0
  38. package/dist/live-open.js +125 -0
  39. package/dist/live-records.d.ts +10 -1
  40. package/dist/live-records.js +16 -5
  41. package/dist/live.d.ts +54 -22
  42. package/dist/live.js +218 -69
  43. package/dist/operate.js +3 -2
  44. package/dist/peek-cli.d.ts +10 -3
  45. package/dist/peek-cli.js +17 -8
  46. package/dist/planner.js +1 -1
  47. package/dist/provider-limits.d.ts +3 -1
  48. package/dist/provider-limits.js +5 -2
  49. package/dist/provider.js +6 -12
  50. package/dist/release-adapters.js +4 -0
  51. package/dist/release.d.ts +3 -0
  52. package/dist/release.js +3 -0
  53. package/dist/repository-context-ui.js +2 -1
  54. package/dist/run-efficiency.d.ts +3 -3
  55. package/dist/run-efficiency.js +20 -8
  56. package/dist/scout.d.ts +3 -0
  57. package/dist/scout.js +5 -3
  58. package/dist/serve.js +34 -23
  59. package/dist/server/agents-live-lead.d.ts +47 -0
  60. package/dist/server/agents-live-lead.js +116 -0
  61. package/dist/server/agents-live-room.d.ts +23 -0
  62. package/dist/server/agents-live-room.js +88 -0
  63. package/dist/server/chat.js +84 -30
  64. package/dist/server/chrome.js +2 -2
  65. package/dist/server/console.css +68 -40
  66. package/dist/server/live.d.ts +4 -0
  67. package/dist/server/live.js +42 -2
  68. package/dist/server/pages.js +81 -1
  69. package/dist/server/render-chat.d.ts +15 -1
  70. package/dist/server/render-chat.js +14 -7
  71. package/dist/server/render-pages.d.ts +8 -4
  72. package/dist/server/render-pages.js +55 -50
  73. package/dist/server/render-tasks.js +10 -3
  74. package/dist/server/route-table.js +5 -1
  75. package/dist/server/runtime.d.ts +1 -1
  76. package/dist/server/settings.js +3 -3
  77. package/dist/server/task-views.js +1 -1
  78. package/dist/server/tasks.js +59 -41
  79. package/dist/store.d.ts +31 -1
  80. package/dist/store.js +75 -2
  81. package/dist/stream-scope.d.ts +18 -0
  82. package/dist/stream-scope.js +28 -0
  83. package/dist/subscription-chat.js +5 -3
  84. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { html, postForm } from "./html.js";
2
+ import { framedSection } from './knowledge-ui.js';
2
3
  export function repositoryContextHtml(repo, query, result, csrf) {
3
4
  const root = `/settings/knowledge?repo=${encodeURIComponent(repo)}`;
4
- return html `<section class="knowledge repository-context"><h2>Relevant code</h2><form method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}</section>`;
5
+ return framedSection('Relevant code', html `<form class="field-row" method="get" action="/settings/knowledge"><input type="hidden" name="repo" value="${repo}"><label>Find code<input name="q" value="${query}" maxlength="1000" placeholder="A feature, file or symbol"></label><button type="submit">Search</button></form>${result === null ? '' : html `<p class="meta">${result.index.engine === 'typescript-ast' ? 'Code relationships' : 'Source search'}${result.mode === 'impact' ? ' · possible change impact' : ''}</p>${result.excerpts.length ? result.excerpts.map(one => html `<details><summary>${one.file}:${one.line}${one.symbol ? ` · ${one.symbol}` : ''}</summary><p class="meta">${one.reason}</p><pre>${one.text}</pre><a href="${root}&mode=impact&q=${encodeURIComponent(one.file)}">What this may affect</a></details>`) : html `<p>No matching source was found in this bounded search.</p>`}${result.relationships.length ? html `<details><summary>Related files</summary><ul>${result.relationships.map(one => html `<li>${one.from} → ${one.to} · ${one.kind}</li>`)}</ul></details>` : ''}<details><summary>Context details</summary><p>${result.index.status}${result.checkout ? ` · checkout ${result.checkout.head.slice(0, 8)}` : ''}</p>${result.warnings.map(one => html `<p>${one}</p>`)}</details>`}${csrf ? html `<details><summary>Refresh code index</summary>${postForm("/settings/knowledge/refresh", html `<p>Builds a local map of TypeScript and JavaScript imports. Uses no model.</p><button type="submit">Refresh index</button>`, { hidden: { repo } })}</details>` : ''}`, 'repository-context');
5
6
  }
@@ -8,7 +8,7 @@
8
8
  * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
9
  * times. No command text is ever stored here: repeats are counted, never quoted.
10
10
  */
11
- import type { Run, Store } from "./store.js";
11
+ import { type Run, type Store } from "./store.js";
12
12
  import type { LiveActivityKind, LiveRecord } from "./live-records.js";
13
13
  export declare const RUN_EFFICIENCY_VERSION = 3;
14
14
  /** Budgets for one derivation: never more than the live file's own cap, and a bounded record count. */
@@ -62,10 +62,10 @@ export declare function usageOf(run: Pick<Run, "provider" | "model" | "tokensIn"
62
62
  * others their times are partly made up (Codex reports a step when it completes, ACP agents may report late), so no
63
63
  * duration is claimed for them. */
64
64
  export declare function stepsOf(records: Iterable<LiveRecord>, truncated: string | null, measured?: boolean): RunEfficiency["steps"];
65
- /** Derive a finished run's How it ran from its records. */
65
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
66
66
  export declare function deriveRunEfficiency(run: Run & {
67
67
  usageJson?: string | null;
68
- }, evidenceRoot: string, now: Date): RunEfficiency;
68
+ }, evidenceRoot: string, now: Date, commands?: boolean): RunEfficiency;
69
69
  /**
70
70
  * A finished run's How it ran: the saved one when it was derived by this version, else derived now and saved (once:
71
71
  * a row whose steps were complete or have expired is final). An unfinished run is derived but never saved.
@@ -1,3 +1,14 @@
1
+ /**
2
+ * How a run went (docs/plans/run-efficiency.md): derived from the run's own records, display only. It never changes a
3
+ * completion, an approval, a charge or a check verdict, and it claims nothing its sources don't hold — an unreported
4
+ * number is unknown (null, shown as a dash), never zero.
5
+ *
6
+ * Sources, and what each can say: the run row (wall times, tokens, the provider's raw usage with its cache counts, the
7
+ * agent's turns where reported) is kept with the run; the live records (`<evidence>/live/<run>.jsonl`, display-only,
8
+ * kept 24 hours) are read once, bounded, through the descriptor-safe live reader, for step counts and observed step
9
+ * times. No command text is ever stored here: repeats are counted, never quoted.
10
+ */
11
+ import { runLiveCommands } from "./store.js";
1
12
  import { closeSync, constants, fstatSync, lstatSync, openSync, readSync } from "node:fs";
2
13
  import { join } from "node:path";
3
14
  import { LIVE_RETAIN_MS, legacyLiveLogPath, liveLogPath, liveWindowRecords } from "./live.js";
@@ -95,7 +106,7 @@ const noSteps = (state, reason) => ({ state, reason, total: null, failed: null,
95
106
  * directory), measured once, and never more than the budget read whatever the file does meanwhile; then parsed line by
96
107
  * line. A file over the budget, in the older text format, with lines that don't read, or cut or capped while the agent
97
108
  * ran, says its steps are incomplete. Null when there is no step file at all. */
98
- function readRecords(evidenceRoot, runId) {
109
+ function readRecords(evidenceRoot, runId, commands) {
99
110
  try {
100
111
  const dir = lstatSync(join(evidenceRoot, "live"));
101
112
  if (!dir.isDirectory() || dir.isSymbolicLink())
@@ -154,10 +165,10 @@ function readRecords(evidenceRoot, runId) {
154
165
  whole.length = EFFICIENCY_RECORDS;
155
166
  const keys = { inKey: false };
156
167
  // One record per line: a whole line that doesn't read is a record lost.
157
- const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, keys);
168
+ const read = liveWindowRecords({ text: whole.length === 0 ? "" : `${whole.join("\n")}\n`, format: "records" }, commands, keys);
158
169
  const lost = whole.length - read.length;
159
170
  // A finished run's last line without its newline is read like any other; one that doesn't read is a record lost.
160
- const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, keys);
171
+ const tail = unfinished.trim() === "" ? [] : liveWindowRecords({ text: `${unfinished}\n`, format: "records" }, commands, keys);
161
172
  const records = [...read, ...tail];
162
173
  const lostAll = lost + (unfinished.trim() !== "" && tail.length === 0 ? 1 : 0);
163
174
  if (records.length === 0 && text.trim() !== "")
@@ -170,10 +181,10 @@ function readRecords(evidenceRoot, runId) {
170
181
  : null;
171
182
  return { records, truncated };
172
183
  }
173
- /** Derive a finished run's How it ran from its records. */
174
- export function deriveRunEfficiency(run, evidenceRoot, now) {
184
+ /** Derive a finished run's How it ran from its records, read under the run's own captured "Show commands" answer. */
185
+ export function deriveRunEfficiency(run, evidenceRoot, now, commands = false) {
175
186
  const finished = run.finishedAt;
176
- const read = readRecords(evidenceRoot, run.id);
187
+ const read = readRecords(evidenceRoot, run.id, commands);
177
188
  const steps = read !== null && "unreadable" in read ? noSteps("truncated", read.unreadable)
178
189
  : read !== null ? stepsOf(read.records, read.truncated, run.provider === "claude")
179
190
  : finished !== null && now.getTime() - Date.parse(finished) > LIVE_RETAIN_MS ? noSteps("expired", "Step details are kept for a day after a run; this one's have expired.")
@@ -196,8 +207,9 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
196
207
  return null;
197
208
  const usageJson = store.handle.prepare("SELECT usage_json FROM run WHERE id = ?").get(runId)?.["usage_json"];
198
209
  const full = { ...run, usageJson: typeof usageJson === "string" ? usageJson : null };
210
+ const commands = runLiveCommands(store, runId);
199
211
  if (run.finishedAt === null)
200
- return deriveRunEfficiency(full, evidenceRoot, now);
212
+ return deriveRunEfficiency(full, evidenceRoot, now, commands);
201
213
  let saved;
202
214
  try {
203
215
  saved = store.handle.prepare("SELECT parser_version, payload_json FROM run_efficiency WHERE run = ?").get(runId);
@@ -211,7 +223,7 @@ export function runEfficiencyOf(store, evidenceRoot, runId, now) {
211
223
  }
212
224
  catch { /* derived again below */ }
213
225
  }
214
- const derived = deriveRunEfficiency(full, evidenceRoot, now);
226
+ const derived = deriveRunEfficiency(full, evidenceRoot, now, commands);
215
227
  const payload = JSON.stringify(derived);
216
228
  // A row too large for its column (it can't be: the counts are bounded) is shown, never saved.
217
229
  // Saving is a convenience: a store that can't take the row (read-only, busy) still shows what was derived.
package/dist/scout.d.ts CHANGED
@@ -131,6 +131,9 @@ export declare function readReportImage(folder: string, file: string): {
131
131
  };
132
132
  /** The shorten turn's ask: the fields over their limits, and the same report again within them. */
133
133
  export declare function shortenReportBrief(over: readonly ReportProblem[], reportFile: string, structured: boolean): string;
134
+ /** Whether a stream event shows the session writing its plan-mode plan
135
+ * file (~/.claude/plans/…) or handing a plan to ExitPlanMode. */
136
+ export declare function touchesPlanFile(event: Record<string, unknown>): boolean;
134
137
  /** A cited link without its secrets: credential-shaped query values and path parts become REDACTED, a fragment
135
138
  * that carries one is dropped, and a link still showing one keeps only its origin. Unchanged when it has none. */
136
139
  export declare function scrubUrl(text: string): {
package/dist/scout.js CHANGED
@@ -37,6 +37,7 @@ import { grantOf, researchToolsOf } from "./mcp-connect.js";
37
37
  import { startScoutProxy } from "./scout-net.js";
38
38
  import * as browserCheck from "./scout-browser.js";
39
39
  import { invokeAgent } from "./invoke.js";
40
+ import { topLevelEvent } from "./stream-scope.js";
40
41
  const GIT = "git";
41
42
  const AGENT_ENV_DENYLIST = [...TELEGRAM_TOKEN_ENVS];
42
43
  const DEFAULT_SCOUT_TIMEOUT_MS = 20 * 60_000;
@@ -298,7 +299,7 @@ async function scoutWith(store, request, outputDir) {
298
299
  }
299
300
  // The live window (peek): the same transcript file the builder keeps,
300
301
  // so `toolroll peek` and the run page can watch this session too.
301
- const liveLog = openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree));
302
+ const liveLog = openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock()));
302
303
  // Claude's report rides the terminal result event (run 2334's fix): plan
303
304
  // mode only lets the session write its own plan file. Codex has no
304
305
  // structured output and keeps the mailbox file.
@@ -706,8 +707,9 @@ export function shortenReportBrief(over, reportFile, structured) {
706
707
  }
707
708
  /** Whether a stream event shows the session writing its plan-mode plan
708
709
  * file (~/.claude/plans/…) or handing a plan to ExitPlanMode. */
709
- function touchesPlanFile(event) {
710
- if (event["type"] !== "assistant")
710
+ export function touchesPlanFile(event) {
711
+ // The session's own calls only: a subagent's (parent_tool_use_id set) is not the scout's plan (stream-scope.ts).
712
+ if (event["type"] !== "assistant" || !topLevelEvent(event))
711
713
  return false;
712
714
  const message = event["message"];
713
715
  if (typeof message !== "object" || message === null)
package/dist/serve.js CHANGED
@@ -139,7 +139,7 @@ import { createSettingsHandlers } from './server/settings.js';
139
139
  import { requestContext } from "./server/request-context.js";
140
140
  import { chromeScript, DEMO_BANNER, DEMO_BANNER_SHORT, focusDocument, KBD_HELP, pinnedTheme, QUEUE_VIEW, refuse, screen, SENSITIVE_INPUT, shell, sidebarScript } from "./server/chrome.js";
141
141
  import { BODY_CAP, form, loginHref, matchTaskPath, page, projectChatHref, redactedPath, redirect, respond, safeReturn, SHUTDOWN_WAIT_MS, taskChatHref } from "./server/http.js";
142
- import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
142
+ import { decisionsFor, LEAD_BY_DEFAULT_FACT, leadBrowserMessages, leadChatVersion, NOT_STARTED_SESSION, TASK_COMPOSER_MODES, teamProposalCardParts } from "./server/render-chat.js";
143
143
  import {} from "./server/render-pages.js";
144
144
  import { ssoStepUps, wrongHostPage } from "./server/render-people.js";
145
145
  import { NO_PROJECT, NO_TOUCH_FRAGMENTS, NONCE_CAP, NONCE_TTL_MS, PersistentSessions, SESSION_ABSOLUTE_MS, SESSION_IDLE_MS, sessionWithinLifetime, SIGN_IN_LINK_MS, SIGN_IN_LINK_PATH } from "./server/session.js";
@@ -775,7 +775,9 @@ export function createDecisionServer(options) {
775
775
  // Passive polls authenticate without extending the browser session.
776
776
  const fragmentName = url.searchParams.get("fragment");
777
777
  const workspaceRead = method === "GET" && url.searchParams.get('format') === 'workspace';
778
- const touch = !(method === "GET" && (edgeRoute?.domain === 'live' || workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname) || url.pathname === '/term/state'));
778
+ const touch = !(method === "GET" && (edgeRoute?.domain === 'live' || workspaceRead || (fragmentName !== null && NO_TOUCH_FRAGMENTS.has(fragmentName)) || /^\/code\/[a-f0-9]{32}\/state$/.test(url.pathname) || url.pathname === '/term/state'
779
+ // The Agents view's Live grid only watches: its polls never extend or write the session.
780
+ || url.pathname === '/agents/live' || /^\/agents\/live\/[0-9]{1,15}$/.test(url.pathname) || (url.pathname === '/agents' && url.searchParams.get('view') === 'live')));
779
781
  const refuseBudget = (admitted) => {
780
782
  if (admitted.ok)
781
783
  return false;
@@ -1139,12 +1141,15 @@ export function createDecisionServer(options) {
1139
1141
  const allCoordinatorRows = store.listCoordinatorProposals({ repos: managedRepos(), states: ["pending", "confirmed", "refused"], limit: 30 });
1140
1142
  const coordinatorProposals = focusTask !== null ? allCoordinatorRows.filter(one => focusTask.family.versions.some(version => version.id === one.payload["task"]))
1141
1143
  : chatProject !== null ? allCoordinatorRows.filter(one => one.repo === chatProject) : allCoordinatorRows;
1142
- const opened = store.openLeadThread(who.name, principal.ceilingDigest, now, chatScopeOf(focusTask, chatProject));
1143
- const proposals = store.listLeadProposals(opened.thread.id);
1144
+ // A visit only reads (quiet page visits): this opens no thread. The first message opens it (chatSend), and a
1145
+ // live thread kept under another ceiling is what that message replaces, so it reads here as the earlier one.
1146
+ const found = store.liveLeadThreadFor(who.name, chatScopeOf(focusTask, chatProject));
1147
+ const thread = found !== null && found.ceilingDigest === principal.ceilingDigest ? found : null;
1148
+ const proposals = thread === null ? [] : store.listLeadProposals(thread.id);
1144
1149
  // One reply runs at a time per person; this thread shows it only when
1145
1150
  // the reply is its own.
1146
1151
  const live = store.liveLeadTurnFor(who.name);
1147
- const messages = store.listLeadMessages(opened.thread.id, 40);
1152
+ const messages = thread === null ? [] : store.listLeadMessages(thread.id, 40);
1148
1153
  // The lead's question with buttons, while its reply is the last word in the thread.
1149
1154
  const last = messages.at(-1);
1150
1155
  const asking = last?.role === "assistant" && last.turn !== null && store.getLeadTurn(last.turn)?.approver === who.name ? store.leadAskOpen(last.turn, now) : null;
@@ -1155,9 +1160,10 @@ export function createDecisionServer(options) {
1155
1160
  // Answered questions stay readable above the answer; only their buttons go.
1156
1161
  const asks = new Map(messages.flatMap(one => { const asked = one.role === "assistant" && one.turn !== null ? store.leadAsk(one.turn) : null; return asked === null ? [] : [[asked.turn, asked]]; }));
1157
1162
  // The person's queued messages in this thread: each shows waiting (with Remove) or why it was not sent.
1158
- const queue = new Map(personalQueue.queue.forThread(opened.thread.id).map(row => [row.message, row]));
1159
- // The thread a ceiling change replaced (ruling 9): read after the open, display only.
1160
- const replaced = store.replacedLeadThread(opened.thread);
1163
+ const queue = new Map(thread === null ? [] : personalQueue.queue.forThread(thread.id).map(row => [row.message, row]));
1164
+ // The thread a ceiling change replaced (ruling 9), display only: closed already, or still live under the old ceiling.
1165
+ const replaced = thread !== null ? store.replacedLeadThread(thread)
1166
+ : found !== null && found.scope.kind === "lead" && store.listLeadMessages(found.id, 1).length > 0 ? found : null;
1161
1167
  const previousProposals = replaced === null ? [] : store.listLeadProposals(replaced.id);
1162
1168
  return {
1163
1169
  previous: replaced === null ? null : { messages: store.listLeadMessages(replaced.id, 40), proposals: previousProposals, decisions: decisionsFor(store, previousProposals) },
@@ -1169,7 +1175,7 @@ export function createDecisionServer(options) {
1169
1175
  proposals,
1170
1176
  decisions: decisionsFor(store, [...proposals, ...coordinatorProposals]),
1171
1177
  coordinatorProposals,
1172
- pending: live !== null && live.thread === opened.thread.id ? live : null,
1178
+ pending: live !== null && thread !== null && live.thread === thread.id ? live : null,
1173
1179
  recent: store.recentLeadTurns(who.name, 5),
1174
1180
  };
1175
1181
  }
@@ -1182,7 +1188,9 @@ export function createDecisionServer(options) {
1182
1188
  /** The Ask panel (v77): the conversation a task, result or project page
1183
1189
  * docks beside itself — that page's own thread, the same session and
1184
1190
  * cards as /chat. Null when this person has no lead chat here. A page
1185
- * view may start a membership conversation, as /chat does. */
1191
+ * view starts nothing (quiet page visits): with no membership
1192
+ * conversation yet, the panel binds its sends to NOT_STARTED_SESSION and
1193
+ * the first message starts one (chatSend). */
1186
1194
  function dockedConversation(who, focusTask, chatProject, now, back, resultRunId = null) {
1187
1195
  if (who.via !== "cookie" || who.role !== "approver" || (focusTask === null && chatProject === null))
1188
1196
  return null;
@@ -1190,16 +1198,14 @@ export function createDecisionServer(options) {
1190
1198
  const principal = enabled.ok ? leadPrincipal(who) : null;
1191
1199
  if (!enabled.ok || principal === null)
1192
1200
  return null;
1193
- let session = store.activeLeadSession(who.name);
1194
- if ((session === null || session.ceilingDigest !== principal.ceilingDigest) && enabled.billing === "subscription" && !requestContext.getStore()?.workspaceRead) {
1195
- startLeadConversation(who, principal, enabled, 0, false, now);
1196
- session = store.activeLeadSession(who.name);
1197
- }
1198
- if (session === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation)
1201
+ const found = store.activeLeadSession(who.name);
1202
+ const notStarted = (found === null || found.ceilingDigest !== principal.ceilingDigest) && enabled.billing === "subscription";
1203
+ const session = notStarted ? null : found;
1204
+ if (!notStarted && (session === null || session.ceilingDigest !== principal.ceilingDigest || session.approverGeneration !== principal.generation))
1199
1205
  return null;
1200
1206
  const rows = leadConversationRows(who, principal, focusTask, now, chatProject);
1201
1207
  return {
1202
- sessionId: session.id, user: session.approver, version: leadChatVersion({ ...rows, focusTask }),
1208
+ sessionId: session?.id ?? NOT_STARTED_SESSION, user: session?.approver ?? who.name, version: leadChatVersion({ ...rows, focusTask }),
1203
1209
  messages: leadBrowserMessages(rows, back, { task: focusTask?.id ?? null, project: chatProject }),
1204
1210
  pendingTurnId: rows.pending?.id ?? null, requestId: randomBytes(16).toString("hex"), maxChars: LEAD_MESSAGE_MAX_CHARS,
1205
1211
  taskId: focusTask?.id ?? null, resultRunId, project: focusTask === null ? chatProject : null,
@@ -1210,11 +1216,17 @@ export function createDecisionServer(options) {
1210
1216
  /** Start the person's lead conversation: the session every later turn
1211
1217
  * debits (a membership spends nothing; direct API gets a per-conversation
1212
1218
  * ceiling) and its thread. Starting ends any older session. */
1213
- function startLeadConversation(who, principal, enabled, ceilingUsd, follow, now) {
1219
+ function startLeadConversation(who, principal, enabled, ceilingUsd, follow, now, scope = LEAD_THREAD, request) {
1220
+ // A page's first send (quiet page visits) starts it under that send's request: the start and its record are one
1221
+ // write, so a retry of the same request finds the conversation it started and never starts another.
1222
+ if (request !== undefined) {
1223
+ return store.transact(() => store.replay({ idempotencyKey: `mate-start:${who.name}:${request}`, actor: who.name, at: now }, "mate-start", () => ({ session: startLeadConversation(who, principal, enabled, ceilingUsd, follow, now, scope) })).session);
1224
+ }
1214
1225
  const ceilingMicrousd = enabled.billing === "subscription" ? 0 : Math.round(ceilingUsd * 1_000_000);
1215
1226
  const termsDigest = createHash("sha256").update(`${ceilingMicrousd}\n${principal.ceilingDigest}`).digest("hex");
1216
1227
  const sessionId = store.mintLeadSession({ approver: who.name, approverGeneration: principal.generation, credentialKey: enabled.credentialKey, ceilingMicrousd, ceilingDigest: principal.ceilingDigest, termsDigest }, now);
1217
- const thread = store.openLeadThread(who.name, principal.ceilingDigest, now).thread;
1228
+ // The thread the first message goes to (quiet page visits: a task's first message opens only the task's thread).
1229
+ const thread = store.openLeadThread(who.name, principal.ceilingDigest, now, scope).thread;
1218
1230
  if (follow)
1219
1231
  configureLeadFollow(store, principal, store.getLeadSession(sessionId), thread, true, now);
1220
1232
  leadSaid.delete(who.session.csrf);
@@ -1323,7 +1335,7 @@ export function createDecisionServer(options) {
1323
1335
  function paletteIndexTag(project) {
1324
1336
  const admitted = project === null ? admissionList() : null;
1325
1337
  const entries = [
1326
- { label: "work", href: "/work" },
1338
+ { label: "tasks", href: "/work" },
1327
1339
  { label: "needs you", href: "/work?view=needs-you" },
1328
1340
  { label: "inbox", href: "/" },
1329
1341
  { label: "board", href: "/board?scope=all" },
@@ -1332,7 +1344,6 @@ export function createDecisionServer(options) {
1332
1344
  { label: "flows", href: "/flows" },
1333
1345
  { label: "done", href: "/done" },
1334
1346
  { label: "review cockpit", href: "/review" },
1335
- { label: "task list", href: "/tasks" },
1336
1347
  { label: "fleet", href: "/fleet" },
1337
1348
  { label: "activity", href: "/activity" },
1338
1349
  { label: "action ledger", href: "/ledger" },
@@ -1347,8 +1358,8 @@ export function createDecisionServer(options) {
1347
1358
  entries.push({ label: `${one.id} — ${one.title}`, href: `/t/${encodeURIComponent(one.id)}` });
1348
1359
  }
1349
1360
  const saturated = open.length > 200;
1350
- const safeEntries = restricted() ? entries.filter(one => ["/work", "/work?view=needs-you", "/", "/board", "/flows", "/done", "/review", "/tasks", "/runs", "/projects", "/ledger"].includes(one.href) || one.href.startsWith("/t/")) : entries;
1351
- return jsonScript(saturated ? [...safeEntries, { label: "… more in the task list", href: "/tasks" }] : safeEntries, { id: "palette-index" });
1361
+ const safeEntries = restricted() ? entries.filter(one => ["/work", "/work?view=needs-you", "/", "/board", "/flows", "/done", "/review", "/runs", "/projects", "/ledger"].includes(one.href) || one.href.startsWith("/t/")) : entries;
1362
+ return jsonScript(saturated ? [...safeEntries, { label: "… more in Tasks", href: "/work" }] : safeEntries, { id: "palette-index" });
1352
1363
  }
1353
1364
  /**
1354
1365
  * The palette cache (arc 4, finding 6), and its honest contract: a
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Lead tiles on the Live grid (docs/plans/agents-view.md slice 8): the viewer's OWN lead chat turns, and only through
3
+ * the chat room's own admission — an approver with a lead standing, the thread theirs, open, the newest for its scope,
4
+ * never a team conversation, under the ceiling it opened with, and its project or task still theirs to see. Team
5
+ * threads wait for their own admission. The words go through one stateful redactor per turn (one key state carried
6
+ * across every step and every line of the turn's current snapshot), then controls out, then a cap. Memory only: the
7
+ * tile is the live turn's, and leaves with it (60 s after the turn ends).
8
+ */
9
+ import type { LeadLiveStep } from "../lead-progress.js";
10
+ import type { ServerRuntime } from "./runtime.js";
11
+ import type { Who } from "./session.js";
12
+ /** A lead tile keeps at most this many lines, and this many bytes of them (the newest). */
13
+ export declare const LEAD_LINES = 200, LEAD_BYTES: number;
14
+ export type LeadTile = {
15
+ thread: number;
16
+ /** The turn being answered (lead.ts's turn id), once the turn has said it. */
17
+ turn: number | null;
18
+ scope: "lead" | "project" | "task";
19
+ title: string;
20
+ project: string | null;
21
+ state: "working" | "done" | "failed";
22
+ startedAt: string;
23
+ endedAt: string | null;
24
+ /** When each step began. */
25
+ stepAt: string[];
26
+ /** What it is doing now, in fixed words; null once it ended. */
27
+ doing: string | null;
28
+ lines: string[];
29
+ /** Whether older lines were cut to fit. */
30
+ earlier: boolean;
31
+ chatHref: string;
32
+ taskHref: string | null;
33
+ };
34
+ /** One turn's lines, redacted as one stream: every step's tools and words in order through ONE key state (a key block
35
+ * opened in one step stays hidden into the next), each line cleaned and cut, then the newest kept within the caps.
36
+ * `confirmed[i]`: step i's text is its finished answer, secret-checked whole. */
37
+ export declare function leadLines(steps: readonly LeadLiveStep[], confirmed?: readonly boolean[]): {
38
+ lines: string[];
39
+ earlier: boolean;
40
+ };
41
+ type LeadDeps = Pick<ServerRuntime, "store" | "liveTurns" | "leadPrincipal" | "familyOf" | "visible" | "managedRepos">;
42
+ /** The viewer's own live lead turns, admitted as the chat room admits them, proved now. */
43
+ export declare function leadTilesFor(deps: LeadDeps, who: Who): LeadTile[];
44
+ /** The tiles projected earlier that are still this viewer's right now (the whole admission proved again, with the
45
+ * session as it is saved now): what is delivered is never more than what is admitted at the moment of delivery. */
46
+ export declare function leadsStillAdmitted(deps: LeadDeps, who: Who, tiles: readonly LeadTile[]): LeadTile[];
47
+ export {};
@@ -0,0 +1,116 @@
1
+ import { capLive, cleanLiveText, HIDDEN_KEY_BLOCK, HIDDEN_LINE, liveLine, redactLiveLines } from "../live-records.js";
2
+ import { projectChatHref, taskChatHref } from "./http.js";
3
+ /** A lead tile keeps at most this many lines, and this many bytes of them (the newest). */
4
+ export const LEAD_LINES = 200, LEAD_BYTES = 32 * 1024;
5
+ /** A tool's line: fixed copy from lead-progress.ts (labels and failure reasons), still cleaned and cut. */
6
+ function toolLine(tool) {
7
+ return liveLine(tool.state === "running" ? `· ${tool.label}…` : tool.state === "failed" ? `· ${tool.reason}` : `· ${tool.label}`);
8
+ }
9
+ /** One step's words, redacted, with only what is settled: the last word of the step's text waits for the space after
10
+ * it unless the provider's finished answer for that step was confirmed (lead.ts sends it once, secret-checked whole).
11
+ * A snapshot that would have completed a credential is never stored (lead.ts drops it), so the one kept can end
12
+ * part-way through one: ending the turn or starting the next step confirms nothing. The last line is redacted on its
13
+ * own (after the rest, with the same key state), the whole line checked for secrets first, and its unfinished word
14
+ * found in the line as shown (invisible characters removed), so no hidden character can stand in for the space. */
15
+ function stepLines(text, keys, confirmed) {
16
+ const raw = text.split(/\r?\n/);
17
+ const lastRaw = raw.pop() ?? "";
18
+ const out = redactLiveLines(raw, keys);
19
+ // Empty when the last line is inside a key block (never shown).
20
+ for (const shown of redactLiveLines([lastRaw], keys)) {
21
+ const whole = shown === HIDDEN_LINE || shown === HIDDEN_KEY_BLOCK || confirmed || /\s$/.test(cleanLiveText(lastRaw));
22
+ out.push((whole ? shown : shown.replace(/\S+$/, "")).trimEnd());
23
+ }
24
+ while (out.length > 0 && out[out.length - 1].trim() === "")
25
+ out.pop();
26
+ return out;
27
+ }
28
+ /** One turn's lines, redacted as one stream: every step's tools and words in order through ONE key state (a key block
29
+ * opened in one step stays hidden into the next), each line cleaned and cut, then the newest kept within the caps.
30
+ * `confirmed[i]`: step i's text is its finished answer, secret-checked whole. */
31
+ export function leadLines(steps, confirmed = []) {
32
+ const keys = { inKey: false };
33
+ const all = [];
34
+ steps.forEach((step, index) => {
35
+ // Tools are not the conversation's text: they never open or close a key block, and inside one they are not shown.
36
+ for (const tool of step.toolCalls)
37
+ if (!keys.inKey)
38
+ all.push(toolLine(tool));
39
+ if (step.text !== "")
40
+ all.push(...stepLines(step.text, keys, confirmed[index] === true));
41
+ });
42
+ const kept = [];
43
+ let bytes = 0;
44
+ for (let index = all.length - 1; index >= 0 && kept.length < LEAD_LINES; index -= 1) {
45
+ const one = capLive(all[index], LEAD_BYTES);
46
+ const size = Buffer.byteLength(one, "utf8") + 1;
47
+ if (bytes + size > LEAD_BYTES)
48
+ break;
49
+ kept.unshift(one);
50
+ bytes += size;
51
+ }
52
+ return { lines: kept, earlier: kept.length < all.length };
53
+ }
54
+ /** What the turn is doing now, in fixed words only. */
55
+ function doingOf(turn) {
56
+ if (turn.done)
57
+ return null;
58
+ const step = turn.steps.at(-1);
59
+ const running = step?.toolCalls.filter(tool => tool.state === "running").at(-1);
60
+ if (running !== undefined)
61
+ return running.label;
62
+ return step !== undefined && step.text.trim() !== "" ? "Writing a reply" : "Thinking";
63
+ }
64
+ /** One thread admitted for this viewer now, as the chat room admits (server/live.ts, room "chat"); null when refused. */
65
+ function admitLead(deps, who, id) {
66
+ const { store, leadPrincipal, familyOf, visible, managedRepos } = deps;
67
+ if (who.via !== "cookie" || who.role !== "approver")
68
+ return null;
69
+ const principal = leadPrincipal(who);
70
+ if (principal === null)
71
+ return null;
72
+ const thread = store.getLeadThread(id);
73
+ if (thread === null || thread.approver !== who.name || thread.closedAt !== null)
74
+ return null;
75
+ // The owner's current thread for its scope (never a team conversation's), under the ceiling it opened with.
76
+ if (store.liveLeadThreadFor(who.name, thread.scope)?.id !== id || thread.ceilingDigest !== principal.ceilingDigest)
77
+ return null;
78
+ if (thread.scope.kind === "project") {
79
+ const repo = thread.scope.key;
80
+ if (!visible(repo) || !managedRepos().includes(repo) || !store.accountCanAccess(who.name, repo))
81
+ return null;
82
+ return { scope: "project", title: "Project chat", project: repo, chatHref: projectChatHref(repo), taskHref: null };
83
+ }
84
+ if (thread.scope.kind === "task") {
85
+ const family = familyOf(thread.scope.key);
86
+ const repo = family?.root.repo ?? null;
87
+ if (family === null || family.problem !== null || family.root.id !== thread.scope.key || repo === null || !visible(repo) || !store.accountCanAccess(who.name, repo))
88
+ return null;
89
+ return { scope: "task", title: family.root.title, project: repo, chatHref: taskChatHref(family.root.id), taskHref: `/t/${encodeURIComponent(family.root.id)}` };
90
+ }
91
+ return { scope: "lead", title: "Lead chat", project: null, chatHref: "/chat", taskHref: null };
92
+ }
93
+ /** The viewer's own live lead turns, admitted as the chat room admits them, proved now. */
94
+ export function leadTilesFor(deps, who) {
95
+ const tiles = [];
96
+ for (const [id, turn] of deps.liveTurns) {
97
+ const admitted = admitLead(deps, who, id);
98
+ if (admitted === null)
99
+ continue;
100
+ const { lines, earlier } = leadLines(turn.steps, turn.confirmed);
101
+ tiles.push({
102
+ thread: id, turn: turn.turn ?? null, ...admitted,
103
+ state: !turn.done ? "working" : turn.ok ? "done" : "failed",
104
+ startedAt: new Date(turn.startedAt ?? Date.now()).toISOString(),
105
+ endedAt: turn.endedAt === undefined ? null : new Date(turn.endedAt).toISOString(),
106
+ stepAt: (turn.stepAt ?? []).map(at => new Date(at).toISOString()),
107
+ doing: doingOf(turn), lines, earlier,
108
+ });
109
+ }
110
+ return tiles.sort((a, b) => a.thread - b.thread);
111
+ }
112
+ /** The tiles projected earlier that are still this viewer's right now (the whole admission proved again, with the
113
+ * session as it is saved now): what is delivered is never more than what is admitted at the moment of delivery. */
114
+ export function leadsStillAdmitted(deps, who, tiles) {
115
+ return tiles.filter(tile => deps.liveTurns.has(tile.thread) && admitLead(deps, who, tile.thread) !== null);
116
+ }
@@ -0,0 +1,23 @@
1
+ import { type LiveBus, type LiveViewer } from "../live-bus.js";
2
+ import type { Store } from "../store.js";
3
+ /** How often a viewer's runs and files are looked at again. */
4
+ export declare const LIVE_POLL_MS = 2000;
5
+ /** At most this many of a viewer's runs have their files looked at (the grid streams at most this many). */
6
+ export declare const LIVE_WATCHED_RUNS = 24;
7
+ export declare function createAgentsLiveRooms<V extends LiveViewer>(input: {
8
+ store: Store;
9
+ bus: LiveBus;
10
+ evidenceRoot: string;
11
+ localRunner: string | undefined;
12
+ clock: () => Date;
13
+ /** Whether the console shows this project at all (its ceiling). */
14
+ visible: (repo: string | null) => boolean;
15
+ /** The keyed viewer's own lead tiles as they stand now (agents-live-lead.ts), proved from their saved session. */
16
+ leads?: (key: string) => unknown;
17
+ /** Told when a page's room is gone. */
18
+ forget?: (key: string) => void;
19
+ }): {
20
+ rooms: import("../live-bus.js").LiveRooms<string, V>;
21
+ keyOf: (account: string, scope: string, page?: string) => string;
22
+ looked: () => number;
23
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The Live grid's room (docs/plans/agents-view.md §3): one room per viewer and scope, never shared, so one viewer's
3
+ * view is never cached for another. Every LIVE_POLL_MS it re-proves each viewer (before any read and before any send,
4
+ * keep-alives included — createLiveRooms' own boundary), then takes the viewer's fingerprint: the tiles they may see
5
+ * and, for this computer's runs, each live file's identity and size through the checked opener. The page hears only
6
+ * that something it can see moved; the fingerprint is an opaque digest, never sizes or file identities. Expiry needs
7
+ * no database write (the tiles are derived from the clock), and the room goes with its last viewer.
8
+ */
9
+ import { createHash } from "node:crypto";
10
+ import { closeSync } from "node:fs";
11
+ import { agentsLiveOf } from "../agents-live.js";
12
+ import { createLiveRooms } from "../live-bus.js";
13
+ import { openLiveTrusted } from "../live-open.js";
14
+ import { requestContext } from "./request-context.js";
15
+ /** How often a viewer's runs and files are looked at again. */
16
+ export const LIVE_POLL_MS = 2_000;
17
+ /** At most this many of a viewer's runs have their files looked at (the grid streams at most this many). */
18
+ export const LIVE_WATCHED_RUNS = 24;
19
+ /** One file as the room sees it: its identity and size, or that it is missing or unavailable. Never its contents. */
20
+ function fileMark(root, name) {
21
+ const opened = openLiveTrusted(root, name);
22
+ if (typeof opened === "string")
23
+ return opened;
24
+ try {
25
+ return `${opened.gen}/${opened.size}`;
26
+ }
27
+ finally {
28
+ closeSync(opened.fd);
29
+ }
30
+ }
31
+ export function createAgentsLiveRooms(input) {
32
+ const { store, evidenceRoot, localRunner, clock, visible } = input;
33
+ const keyOf = (account, scope, page = "") => JSON.stringify([account, scope, page]);
34
+ /** One look at a run's two files, shared by every viewer's room: at most once per LIVE_POLL_MS per run, however
35
+ * many viewers or bus signals there are (a burst of writes re-proves at once, but never opens files more often). */
36
+ const looked = new Map();
37
+ const marksOf = (runId, stillSees) => {
38
+ // A run's files are this viewer's to look at (or have looked at) only while the run is still theirs and this
39
+ // computer's, proved from its row now — a look another viewer's room took included.
40
+ if (!stillSees())
41
+ return null;
42
+ const now = Date.now();
43
+ const kept = looked.get(runId);
44
+ // One attempt per run per poll, whether it looked or stopped part-way: nothing makes files opened more often.
45
+ if (kept !== undefined && now - kept.at < LIVE_POLL_MS)
46
+ return kept.marks;
47
+ looked.set(runId, { at: now, marks: null });
48
+ const main = fileMark(evidenceRoot, `${runId}.jsonl`);
49
+ if (!stillSees())
50
+ return null;
51
+ const sub = fileMark(evidenceRoot, `${runId}.sub.jsonl`);
52
+ if (!stillSees())
53
+ return null;
54
+ const marks = [main, sub];
55
+ looked.set(runId, { at: now, marks });
56
+ return marks;
57
+ };
58
+ const rooms = createLiveRooms(key => {
59
+ const [account] = JSON.parse(key);
60
+ // Read as the keyed account (its own request context), never as whoever's write woke the bus.
61
+ return requestContext.run({ actor: account, csrf: "", returnTo: "/agents" }, () => {
62
+ const sees = (runId) => () => {
63
+ const run = store.getRun(runId);
64
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
65
+ return run !== null && repo !== null && visible(repo) && store.accountCanAccess(account, repo) && localRunner !== undefined && run.runner === localRunner;
66
+ };
67
+ const tiles = agentsLiveOf(store, clock(), {
68
+ canSee: run => run.repo !== null && visible(run.repo) && store.accountCanAccess(account, run.repo),
69
+ localRunner,
70
+ });
71
+ const marks = tiles.filter(tile => tile.local).slice(0, LIVE_WATCHED_RUNS).map(tile => [tile.runId, marksOf(tile.runId, sees(tile.runId))]);
72
+ for (const [runId] of [...looked])
73
+ if (Date.now() - looked.get(runId).at > 10 * LIVE_POLL_MS)
74
+ looked.delete(runId);
75
+ // The digest's every run proved again from its current row after the looks: one that left view isn't in it.
76
+ const still = (runId) => {
77
+ const run = store.getRun(runId);
78
+ const repo = run === null ? null : store.refForId(run.taskRef)?.repo ?? null;
79
+ return repo !== null && visible(repo) && store.accountCanAccess(account, repo);
80
+ };
81
+ const kept = new Set(tiles.filter(tile => still(tile.runId)).map(tile => tile.runId));
82
+ const seen = tiles.filter(tile => kept.has(tile.runId)).map(tile => [tile.runId, tile.state, tile.why, tile.endedAt]);
83
+ const shownMarks = marks.filter(([runId]) => kept.has(runId) && (!tiles.find(tile => tile.runId === runId)?.local || sees(runId)()));
84
+ return createHash("sha256").update(JSON.stringify([seen, shownMarks, input.leads?.(key) ?? null])).digest("base64url");
85
+ });
86
+ }, null, { bus: input.bus, safetyNetMs: LIVE_POLL_MS, onEmpty: key => input.forget?.(key) });
87
+ return { rooms, keyOf, looked: () => looked.size };
88
+ }