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.
- package/CHANGELOG.md +32 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-turns.js +5 -0
- package/dist/agents-live.d.ts +31 -0
- package/dist/agents-live.js +43 -0
- package/dist/browser/THIRD_PARTY_NOTICES.txt +40 -0
- package/dist/browser/workspace.css +2 -2
- package/dist/browser/workspace.js +31 -30
- package/dist/browser-workspace.d.ts +6 -0
- package/dist/builder.js +1 -1
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-quiet.js +3 -3
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +35 -2
- package/dist/exec.js +13 -16
- package/dist/first-run.d.ts +17 -1
- package/dist/first-run.js +38 -0
- package/dist/invoke.d.ts +13 -0
- package/dist/invoke.js +21 -12
- package/dist/knowledge-ui.d.ts +4 -1
- package/dist/knowledge-ui.js +10 -5
- package/dist/lead-progress.d.ts +4 -1
- package/dist/lead-progress.js +3 -1
- package/dist/lead.js +4 -0
- package/dist/live-bus.d.ts +2 -0
- package/dist/live-bus.js +1 -0
- package/dist/live-children-read.d.ts +52 -0
- package/dist/live-children-read.js +333 -0
- package/dist/live-children.d.ts +73 -0
- package/dist/live-children.js +318 -0
- package/dist/live-detail.d.ts +4 -3
- package/dist/live-detail.js +9 -5
- package/dist/live-input.js +7 -8
- package/dist/live-main-read.d.ts +19 -0
- package/dist/live-main-read.js +91 -0
- package/dist/live-open.d.ts +28 -0
- package/dist/live-open.js +125 -0
- package/dist/live-records.d.ts +10 -1
- package/dist/live-records.js +16 -5
- package/dist/live.d.ts +54 -22
- package/dist/live.js +218 -69
- package/dist/operate.js +3 -2
- package/dist/peek-cli.d.ts +10 -3
- package/dist/peek-cli.js +17 -8
- package/dist/planner.js +1 -1
- package/dist/provider-limits.d.ts +3 -1
- package/dist/provider-limits.js +5 -2
- package/dist/provider.js +6 -12
- package/dist/release-adapters.js +4 -0
- package/dist/release.d.ts +3 -0
- package/dist/release.js +3 -0
- package/dist/repository-context-ui.js +2 -1
- package/dist/run-efficiency.d.ts +3 -3
- package/dist/run-efficiency.js +20 -8
- package/dist/scout.d.ts +3 -0
- package/dist/scout.js +5 -3
- package/dist/serve.js +34 -23
- package/dist/server/agents-live-lead.d.ts +47 -0
- package/dist/server/agents-live-lead.js +116 -0
- package/dist/server/agents-live-room.d.ts +23 -0
- package/dist/server/agents-live-room.js +88 -0
- package/dist/server/chat.js +84 -30
- package/dist/server/chrome.js +2 -2
- package/dist/server/console.css +68 -40
- package/dist/server/live.d.ts +4 -0
- package/dist/server/live.js +42 -2
- package/dist/server/pages.js +81 -1
- package/dist/server/render-chat.d.ts +15 -1
- package/dist/server/render-chat.js +14 -7
- package/dist/server/render-pages.d.ts +8 -4
- package/dist/server/render-pages.js +55 -50
- package/dist/server/render-tasks.js +10 -3
- package/dist/server/route-table.js +5 -1
- package/dist/server/runtime.d.ts +1 -1
- package/dist/server/settings.js +3 -3
- package/dist/server/task-views.js +1 -1
- package/dist/server/tasks.js +59 -41
- package/dist/store.d.ts +31 -1
- package/dist/store.js +75 -2
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subscription-chat.js +5 -3
- 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 `<
|
|
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
|
}
|
package/dist/run-efficiency.d.ts
CHANGED
|
@@ -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
|
|
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.
|
package/dist/run-efficiency.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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(
|
|
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(
|
|
1159
|
-
// The thread a ceiling change replaced (ruling 9):
|
|
1160
|
-
const replaced = store.replacedLeadThread(
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
|
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
|
-
|
|
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: "
|
|
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", "/
|
|
1351
|
-
return jsonScript(saturated ? [...safeEntries, { label: "… more in
|
|
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
|
+
}
|