toolroll 0.9.73 → 0.9.75
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -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/assignment.d.ts +3 -0
- package/dist/assignment.js +27 -8
- 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 +7 -1
- package/dist/builder.js +1 -1
- package/dist/chat-acceptance.d.ts +4 -1
- package/dist/chat-acceptance.js +13 -3
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-quiet.js +3 -3
- package/dist/cli.js +1 -1
- package/dist/coding-handoff.js +4 -3
- package/dist/coding-ui.js +2 -2
- package/dist/coding-workspace.js +16 -4
- package/dist/contracts/console-api.d.ts +5 -1
- package/dist/contracts/console-api.js +10 -0
- package/dist/control-ui.d.ts +3 -2
- package/dist/control-ui.js +7 -2
- package/dist/demo.js +5 -3
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +4 -1
- package/dist/desktop-update.js +43 -9
- package/dist/dispose.js +35 -2
- package/dist/exec.js +13 -16
- package/dist/first-run.d.ts +20 -1
- package/dist/first-run.js +71 -0
- package/dist/flow-card-task.js +3 -2
- package/dist/flow-unattended.d.ts +2 -0
- package/dist/flow-unattended.js +12 -1
- package/dist/integrations.d.ts +4 -3
- package/dist/integrations.js +5 -6
- 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-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-follow.js +3 -1
- 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/openrouter-models.d.ts +5 -2
- package/dist/openrouter-models.js +7 -4
- package/dist/operate.js +5 -2
- package/dist/peek-cli.d.ts +10 -3
- package/dist/peek-cli.js +17 -8
- package/dist/planner.js +1 -1
- package/dist/project-learning.js +44 -6
- package/dist/project-tools.js +19 -1
- package/dist/provider-limits.d.ts +3 -1
- package/dist/provider-limits.js +5 -2
- package/dist/provider.js +6 -12
- package/dist/pull-request-ui.d.ts +1 -1
- package/dist/pull-request-ui.js +5 -1
- package/dist/push.d.ts +2 -0
- package/dist/push.js +24 -19
- package/dist/read-only-request.d.ts +4 -0
- package/dist/read-only-request.js +15 -0
- 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/repository-context.js +6 -1
- package/dist/result-follow-ups.js +20 -1
- package/dist/result-review.js +5 -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 +196 -29
- package/dist/server/agents-live-filed.d.ts +17 -0
- package/dist/server/agents-live-filed.js +97 -0
- 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 +91 -0
- package/dist/server/chat.js +115 -53
- package/dist/server/chrome.js +2 -2
- package/dist/server/console.css +68 -40
- package/dist/server/http.d.ts +5 -0
- package/dist/server/last-checks.d.ts +17 -0
- package/dist/server/last-checks.js +22 -0
- package/dist/server/live.d.ts +4 -0
- package/dist/server/live.js +42 -2
- package/dist/server/pages.js +98 -4
- package/dist/server/people-tokens.js +7 -4
- package/dist/server/render-chat.d.ts +15 -1
- package/dist/server/render-chat.js +14 -7
- package/dist/server/render-pages.d.ts +10 -5
- package/dist/server/render-pages.js +83 -74
- package/dist/server/render-results.d.ts +11 -2
- package/dist/server/render-results.js +54 -12
- package/dist/server/render-settings.js +3 -3
- package/dist/server/render-tasks.js +13 -3
- package/dist/server/route-table.js +19 -2
- package/dist/server/runtime.d.ts +17 -2
- package/dist/server/settings.js +85 -32
- package/dist/server/task-views.js +1 -2
- package/dist/server/tasks.js +70 -43
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +1 -1
- package/dist/storage.js +2 -1
- package/dist/store.d.ts +34 -1
- package/dist/store.js +108 -11
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subagent-work.js +3 -0
- package/dist/subscription-chat.js +5 -3
- package/dist/task-status.d.ts +28 -1
- package/dist/task-status.js +93 -11
- package/dist/teardown-git.d.ts +27 -1
- package/dist/teardown-git.js +185 -39
- package/dist/telegram-progress.js +4 -4
- package/dist/telegram-status.js +8 -3
- package/dist/tool-secret-store.js +8 -0
- package/dist/toolroll-update-ui.d.ts +2 -0
- package/dist/toolroll-update-ui.js +4 -3
- package/dist/toolroll-update.js +10 -9
- package/dist/work-index.js +29 -12
- package/dist/workspace-ui.d.ts +5 -0
- package/dist/workspace-ui.js +38 -10
- package/package.json +1 -1
package/dist/live.js
CHANGED
|
@@ -13,17 +13,25 @@
|
|
|
13
13
|
* Fail-soft everywhere: a live view that cannot be written must never
|
|
14
14
|
* break, slow, or reclassify a build.
|
|
15
15
|
*/
|
|
16
|
-
import {
|
|
16
|
+
import { randomBytes } from "node:crypto";
|
|
17
|
+
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
17
18
|
import { join } from "node:path";
|
|
18
19
|
import { scanForSecrets } from "./evidence.js";
|
|
19
20
|
import { createLiveRecorder, parseLiveRecords, redactLiveLines, projectLiveText, serializeLiveRecord } from "./live-records.js";
|
|
21
|
+
import { childLogWriter, createChildRecorder } from "./live-children.js";
|
|
22
|
+
import { topLevelEvent } from "./stream-scope.js";
|
|
20
23
|
export const LIVE_LINE_CAP = 2 * 1024;
|
|
21
24
|
export const LIVE_FILE_CAP = 4 * 1024 * 1024;
|
|
22
25
|
export const LIVE_REDACTED_LINE = "[a credential-shaped line was redacted]";
|
|
23
26
|
/** Finalized runs keep their live file this long for the final drain. */
|
|
24
27
|
export const LIVE_RETAIN_MS = 24 * 60 * 60 * 1000;
|
|
25
|
-
/** The bounded sweep: at most this many FINALIZED files survive a pass
|
|
28
|
+
/** The bounded sweep: at most this many FINALIZED runs' files survive a pass (a run's records and its subagents' file
|
|
29
|
+
* count as one). */
|
|
26
30
|
export const LIVE_FILE_BOUND = 500;
|
|
31
|
+
/** A run's subagent file (docs/plans/agents-view.md §2.3) is never larger than this; its writer stops there. */
|
|
32
|
+
export const LIVE_SUB_FILE_CAP = 16 * 1024 * 1024;
|
|
33
|
+
/** All finished and orphan live files together stay under this; the oldest runs go first past it. */
|
|
34
|
+
export const LIVE_TOTAL_BYTES = 1024 * 1024 * 1024;
|
|
27
35
|
export function liveDir(root) {
|
|
28
36
|
return join(root, "live");
|
|
29
37
|
}
|
|
@@ -33,6 +41,10 @@ export function liveDir(root) {
|
|
|
33
41
|
export function liveLogPath(root, runId) {
|
|
34
42
|
return join(liveDir(root), `${Math.trunc(runId)}.jsonl`);
|
|
35
43
|
}
|
|
44
|
+
/** A run's subagents' records (docs/plans/agents-view.md §2.3): the run's file, swept with it. */
|
|
45
|
+
export function liveSubLogPath(root, runId) {
|
|
46
|
+
return join(liveDir(root), `${Math.trunc(runId)}.sub.jsonl`);
|
|
47
|
+
}
|
|
36
48
|
/** The plain-text file runs wrote before the live conversation; read until it ages out. */
|
|
37
49
|
export function legacyLiveLogPath(root, runId) {
|
|
38
50
|
return join(liveDir(root), `${Math.trunc(runId)}.log`);
|
|
@@ -62,6 +74,9 @@ const toolKind = (name) => TOOL_KINDS.get(typeof name === "string" ? name : "")
|
|
|
62
74
|
*/
|
|
63
75
|
export function liveActivityKind(event) {
|
|
64
76
|
try {
|
|
77
|
+
// A subagent's events, of every type, are not this run's activity (stream-scope.ts).
|
|
78
|
+
if (!topLevelEvent(event))
|
|
79
|
+
return null;
|
|
65
80
|
const type = String(event["type"] ?? "");
|
|
66
81
|
if ((type === "system" && String(event["subtype"] ?? "") === "init") || type === "thread.started" || type === "init")
|
|
67
82
|
return "session";
|
|
@@ -76,9 +91,6 @@ export function liveActivityKind(event) {
|
|
|
76
91
|
return toolKind(event["name"] ?? event["tool"]?.["name"]);
|
|
77
92
|
if (type !== "assistant")
|
|
78
93
|
return null;
|
|
79
|
-
const parent = event["parent_tool_use_id"];
|
|
80
|
-
if (parent !== undefined && parent !== null)
|
|
81
|
-
return null;
|
|
82
94
|
const content = event["message"]?.["content"];
|
|
83
95
|
if (!Array.isArray(content))
|
|
84
96
|
return null;
|
|
@@ -140,7 +152,12 @@ export function openLiveLog(root, runId, options = {}) {
|
|
|
140
152
|
catch {
|
|
141
153
|
return null;
|
|
142
154
|
}
|
|
143
|
-
const
|
|
155
|
+
const recorderOptions = { base: options.base ?? null, commands: options.commands === true, ...(options.clock === undefined ? {} : { clock: options.clock }) };
|
|
156
|
+
// The run's subagents go to their own file (live-children.ts), under the same captured answer; this file is unchanged
|
|
157
|
+
// (the recorder only tells the subagents' side which step it wrote for each delegation).
|
|
158
|
+
const children = createChildRecorder(recorderOptions);
|
|
159
|
+
const recorder = createLiveRecorder({ ...recorderOptions, onStep: children.parentStep });
|
|
160
|
+
const childLog = childLogWriter(root, runId);
|
|
144
161
|
let written = 0;
|
|
145
162
|
let capped = false;
|
|
146
163
|
let closed = false;
|
|
@@ -178,8 +195,13 @@ export function openLiveLog(root, runId, options = {}) {
|
|
|
178
195
|
return {
|
|
179
196
|
observe(event) {
|
|
180
197
|
try {
|
|
181
|
-
|
|
198
|
+
const mine = recorder.observe(event);
|
|
199
|
+
for (const record of mine)
|
|
182
200
|
append(record);
|
|
201
|
+
if (!closed) {
|
|
202
|
+
const before = childLog.due() ? children.checkpoint() : null;
|
|
203
|
+
childLog.append(children.observe(event), before);
|
|
204
|
+
}
|
|
183
205
|
}
|
|
184
206
|
catch {
|
|
185
207
|
// Recording is observational; a malformed event records nothing.
|
|
@@ -195,6 +217,12 @@ export function openLiveLog(root, runId, options = {}) {
|
|
|
195
217
|
catch {
|
|
196
218
|
// the steps simply stay open in the file
|
|
197
219
|
}
|
|
220
|
+
try {
|
|
221
|
+
const before = childLog.due() ? children.checkpoint() : null;
|
|
222
|
+
childLog.append(children.end(), before);
|
|
223
|
+
}
|
|
224
|
+
catch { /* the subagents' steps stay open in theirs */ }
|
|
225
|
+
childLog.close();
|
|
198
226
|
closed = true;
|
|
199
227
|
try {
|
|
200
228
|
closeSync(fd);
|
|
@@ -326,18 +354,42 @@ export function readLiveWindow(root, runId, from, allowTail = false) {
|
|
|
326
354
|
closeSync(fd);
|
|
327
355
|
}
|
|
328
356
|
}
|
|
329
|
-
/** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format.
|
|
330
|
-
|
|
357
|
+
/** A window's complete lines as the plain words the run page and `toolroll peek` show, whatever its format. `commands` is
|
|
358
|
+
* the run's own captured answer (store.ts runLiveCommands), never the file's say. */
|
|
359
|
+
export function liveWindowLines(read, commands, keys = { inKey: false }) {
|
|
331
360
|
if (read.format === "records")
|
|
332
|
-
return projectLiveText(parseLiveRecords(read.text, keys));
|
|
361
|
+
return projectLiveText(withholdCommands(parseLiveRecords(read.text, keys), commands));
|
|
333
362
|
return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "");
|
|
334
363
|
}
|
|
335
|
-
/** A window as records, whatever its format: an older plain file's lines become the agent's words.
|
|
336
|
-
|
|
364
|
+
/** A window as records, whatever its format: an older plain file's lines become the agent's words. Command text and
|
|
365
|
+
* output show only when the run's own captured answer (`commands`) is on. */
|
|
366
|
+
export function liveWindowRecords(read, commands, keys = { inKey: false }) {
|
|
337
367
|
if (read.format === "records")
|
|
338
|
-
return parseLiveRecords(read.text, keys);
|
|
368
|
+
return withholdCommands(parseLiveRecords(read.text, keys), commands);
|
|
339
369
|
return cleanLegacy(read.text, keys).split("\n").filter(one => one !== "").map(line => ({ k: "say", at: "", t: [line] }));
|
|
340
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Enforce a run's captured "Show commands" answer on read: the file is build-writable, so an `open` record, a command
|
|
373
|
+
* step's text or a step's output may say anything. With the answer off, every `open` reads the base tier, command steps
|
|
374
|
+
* lose their text and steps lose their output. (The older plain-text format predates command detail and is unchanged.)
|
|
375
|
+
*/
|
|
376
|
+
export function withholdCommands(records, commands) {
|
|
377
|
+
if (commands)
|
|
378
|
+
return records;
|
|
379
|
+
return records.map(record => {
|
|
380
|
+
if (record.k === "open")
|
|
381
|
+
return record.detail === "base" ? record : { ...record, detail: "base" };
|
|
382
|
+
if (record.k === "step" && record.kind === "command" && record.target !== undefined) {
|
|
383
|
+
const { target: _target, ...rest } = record;
|
|
384
|
+
return rest;
|
|
385
|
+
}
|
|
386
|
+
if (record.k === "done" && record.out !== undefined) {
|
|
387
|
+
const { out: _out, ...rest } = record;
|
|
388
|
+
return rest;
|
|
389
|
+
}
|
|
390
|
+
return record;
|
|
391
|
+
});
|
|
392
|
+
}
|
|
341
393
|
/** Legacy text lines, cleaned again on read: the file may have been altered after it was written. */
|
|
342
394
|
function cleanLegacy(text, keys) {
|
|
343
395
|
const lines = text.split("\n");
|
|
@@ -348,10 +400,9 @@ function cleanLegacy(text, keys) {
|
|
|
348
400
|
return ended ? `${shown.join("\n")}\n` : shown.join("\n");
|
|
349
401
|
}
|
|
350
402
|
/**
|
|
351
|
-
* Enumerate the live directory for the reconcile sweep. Pure listing — the
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* from nothing this plane wrote; aged on mtime like everything else).
|
|
403
|
+
* Enumerate the live directory for the reconcile sweep: names only. Pure listing — the CALLER settles which runs are
|
|
404
|
+
* finalized before it measures anything; this module only refuses to guess. Non-run names are reported with runId
|
|
405
|
+
* null (orphans from nothing this plane wrote; aged on mtime like everything else).
|
|
355
406
|
*/
|
|
356
407
|
export function listLiveFiles(root) {
|
|
357
408
|
let names;
|
|
@@ -361,74 +412,172 @@ export function listLiveFiles(root) {
|
|
|
361
412
|
catch {
|
|
362
413
|
return [];
|
|
363
414
|
}
|
|
364
|
-
|
|
365
|
-
for
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
}
|
|
373
|
-
const match = /^(\d{1,15})\.(?:jsonl|log)$/.exec(name);
|
|
374
|
-
out.push({ runId: match === null ? null : Number(match[1]), fileName: name, mtimeMs });
|
|
375
|
-
}
|
|
376
|
-
return out;
|
|
415
|
+
// A sweep's private claim (QUARANTINE_PREFIX) is never listed: it is the sweep's own, and one it couldn't restore
|
|
416
|
+
// stays for a person to look at rather than be aged out under a name nobody chose.
|
|
417
|
+
return names.filter(name => !name.startsWith(QUARANTINE_PREFIX)).map(name => {
|
|
418
|
+
const match = /^(\d{1,15})\.(jsonl|log|sub\.jsonl)$/.exec(name);
|
|
419
|
+
// The writer stops at its cap and adds one last record; anything far past it is no file this plane wrote.
|
|
420
|
+
const cap = match === null ? null : (match[2] === "sub.jsonl" ? LIVE_SUB_FILE_CAP : LIVE_FILE_CAP) + 64 * 1024;
|
|
421
|
+
return { runId: match === null ? null : Number(match[1]), fileName: name, cap };
|
|
422
|
+
});
|
|
377
423
|
}
|
|
378
|
-
/** Remove one live file, silently: a sweep never throws over display state. */
|
|
379
|
-
export function removeLiveFile(root, fileName) {
|
|
424
|
+
/** Remove one live file, silently: a sweep never throws over display state. True when it is gone now. */
|
|
425
|
+
export function removeLiveFile(root, fileName, unlink = unlinkSync) {
|
|
380
426
|
// The name comes from listLiveFiles' own readdir, never from user input.
|
|
381
427
|
if (fileName.includes("/") || fileName.includes("\\") || fileName.includes(".."))
|
|
382
|
-
return;
|
|
428
|
+
return false;
|
|
383
429
|
try {
|
|
384
|
-
|
|
430
|
+
unlink(join(liveDir(root), fileName));
|
|
431
|
+
return true;
|
|
385
432
|
}
|
|
386
|
-
catch {
|
|
387
|
-
|
|
433
|
+
catch (error) {
|
|
434
|
+
return error.code === "ENOENT";
|
|
388
435
|
}
|
|
389
436
|
}
|
|
437
|
+
/** The prefix of a sweep's private claim on a file it is about to remove (never a name a writer uses). */
|
|
438
|
+
const QUARANTINE_PREFIX = ".sweep-claim-";
|
|
390
439
|
/**
|
|
391
440
|
* The reconcile sweep (arc 1 §2): ACTIVE runs are exempt ABSOLUTELY — the
|
|
392
|
-
* transport's no-truncation assumption depends on it
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
441
|
+
* transport's no-truncation assumption depends on it — and a run's files
|
|
442
|
+
* (its records, its subagents' file, an older text file) go together, as one.
|
|
443
|
+
* A run is settled finished (or absent) BEFORE its files are measured.
|
|
444
|
+
* Finalized runs age from finished_at; orphans (no run row, or a name this
|
|
445
|
+
* plane never writes) age from mtime. Among the finalized-or-orphan: a file
|
|
446
|
+
* far past its own cap goes at once, then the day's retention, then the
|
|
447
|
+
* 500-run bound, then the total byte limit, oldest first, until each limit is
|
|
448
|
+
* really met or nothing eligible is left. Each removal re-proves, under the
|
|
449
|
+
* store's write lock (the one run admission takes), that the run is still
|
|
450
|
+
* not active and the file is the very one measured; otherwise it is kept. A
|
|
451
|
+
* file that can't be removed stays counted and never stops the rest.
|
|
396
452
|
*/
|
|
397
|
-
export function sweepLiveLogs(store, root, now) {
|
|
453
|
+
export function sweepLiveLogs(store, root, now, io = {}) {
|
|
398
454
|
const removed = [];
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
455
|
+
const settled = (runId) => {
|
|
456
|
+
if (runId === null)
|
|
457
|
+
return null;
|
|
458
|
+
try {
|
|
459
|
+
const run = store.getRun(runId);
|
|
460
|
+
return run !== null && run.outcome === null ? "active" : run;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
return "unknown";
|
|
464
|
+
}
|
|
465
|
+
};
|
|
466
|
+
const at = (name) => join(liveDir(root), name);
|
|
467
|
+
const lstat = io.lstat ?? ((path) => lstatSync(path));
|
|
468
|
+
/** A file as it is now: measured, confirmed gone (ENOENT only), or unknown (any other failure: never taken as gone). */
|
|
469
|
+
const measure = (fileName, runId, path = at(fileName)) => {
|
|
470
|
+
try {
|
|
471
|
+
const stat = lstat(path);
|
|
472
|
+
return { fileName, runId, dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs, bytes: stat.isFile() ? stat.size : 0 };
|
|
410
473
|
}
|
|
411
|
-
|
|
412
|
-
|
|
474
|
+
catch (error) {
|
|
475
|
+
return error.code === "ENOENT" ? "gone" : "unknown";
|
|
413
476
|
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
const
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
477
|
+
};
|
|
478
|
+
const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeMs === b.mtimeMs;
|
|
479
|
+
/** The run is still not active, proved under the store's write lock (the one run admission takes) — and only that. */
|
|
480
|
+
const stillSweepable = (runId) => {
|
|
481
|
+
const check = () => { const state = settled(runId); return state !== "active" && state !== "unknown"; };
|
|
482
|
+
try {
|
|
483
|
+
return store.transact === undefined ? check() : store.transact(check);
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
return false;
|
|
487
|
+
}
|
|
488
|
+
};
|
|
489
|
+
/** Put a claimed file back under its name without overwriting whatever may be there now. */
|
|
490
|
+
const restore = (claim, fileName) => {
|
|
491
|
+
try {
|
|
492
|
+
linkSync(at(claim), at(fileName));
|
|
493
|
+
unlinkSync(at(claim));
|
|
494
|
+
}
|
|
495
|
+
catch { /* the name is taken: the claim stays, unlisted */ }
|
|
496
|
+
};
|
|
497
|
+
/**
|
|
498
|
+
* Remove one measured file: its run still not active, then the name atomically claimed under a private name in the
|
|
499
|
+
* live directory, then the claim proved to be the very file measured (dev, inode, size, mtime) and the run still not
|
|
500
|
+
* active — and only then is the private claim deleted. A mismatch is put back without overwriting anything. The
|
|
501
|
+
* store's lock covers the run-state checks only; the filesystem work runs outside it.
|
|
502
|
+
*/
|
|
503
|
+
const remove = (file) => {
|
|
504
|
+
if (file.failed === true)
|
|
505
|
+
return false;
|
|
506
|
+
const done = (gone) => { if (gone)
|
|
507
|
+
removed.push(file.fileName);
|
|
508
|
+
else
|
|
509
|
+
file.failed = true; return gone; };
|
|
510
|
+
io.beforeRemove?.(file.fileName);
|
|
511
|
+
if (file.fileName.includes("/") || file.fileName.includes("..") || !stillSweepable(file.runId))
|
|
512
|
+
return done(false);
|
|
513
|
+
io.afterCheck?.(file.fileName);
|
|
514
|
+
const claim = `${QUARANTINE_PREFIX}${randomBytes(12).toString("hex")}`;
|
|
515
|
+
try {
|
|
516
|
+
renameSync(at(file.fileName), at(claim));
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
return done(error.code === "ENOENT");
|
|
422
520
|
}
|
|
423
|
-
|
|
424
|
-
|
|
521
|
+
const claimed = measure(file.fileName, file.runId, at(claim));
|
|
522
|
+
if (claimed === "gone")
|
|
523
|
+
return done(true);
|
|
524
|
+
if (claimed === "unknown" || !same(claimed, file) || !stillSweepable(file.runId)) {
|
|
525
|
+
restore(claim, file.fileName);
|
|
526
|
+
return done(false);
|
|
425
527
|
}
|
|
528
|
+
if (removeLiveFile(root, claim, io.unlink))
|
|
529
|
+
return done(true);
|
|
530
|
+
restore(claim, file.fileName);
|
|
531
|
+
return done(false);
|
|
532
|
+
};
|
|
533
|
+
// One group per finished run (keyed by id) or per orphan file, aged from the run's end or the file's mtime.
|
|
534
|
+
const groups = new Map();
|
|
535
|
+
// Settle every listed run first, then list again and measure: a file a run wrote (or grew) before it finished is
|
|
536
|
+
// seen at its final size, and one its run created after the first listing is counted with it.
|
|
537
|
+
const states = new Map();
|
|
538
|
+
for (const entry of listLiveFiles(root))
|
|
539
|
+
if (entry.runId !== null && !states.has(entry.runId))
|
|
540
|
+
states.set(entry.runId, settled(entry.runId));
|
|
541
|
+
for (const entry of listLiveFiles(root)) {
|
|
542
|
+
// A run first seen in this listing waits for the next pass: it was never settled before its files were listed.
|
|
543
|
+
if (entry.runId !== null && !states.has(entry.runId))
|
|
544
|
+
continue;
|
|
545
|
+
const state = entry.runId === null ? null : states.get(entry.runId);
|
|
546
|
+
if (state === "active" || state === "unknown")
|
|
547
|
+
continue; // live, or unknown this pass: kept, never guessed
|
|
548
|
+
// Measured only now, after the run is settled finished: its writer is done.
|
|
549
|
+
const file = measure(entry.fileName, entry.runId);
|
|
550
|
+
if (file === "gone" || file === "unknown")
|
|
551
|
+
continue;
|
|
552
|
+
if (entry.cap !== null && file.bytes > entry.cap && remove(file))
|
|
553
|
+
continue;
|
|
554
|
+
const finished = state === null || state.finishedAt === null ? null : Date.parse(state.finishedAt);
|
|
555
|
+
const anchor = finished === null || Number.isNaN(finished) ? file.mtimeMs : finished;
|
|
556
|
+
const key = entry.runId === null ? `file:${entry.fileName}` : `run:${entry.runId}`;
|
|
557
|
+
const group = groups.get(key) ?? { files: [], ageAnchorMs: anchor };
|
|
558
|
+
group.files.push(file);
|
|
559
|
+
group.ageAnchorMs = Math.min(group.ageAnchorMs, anchor);
|
|
560
|
+
groups.set(key, group);
|
|
426
561
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
562
|
+
/** Remove a group's files; what can't be removed stays in it (and in every count after). True when it's empty. */
|
|
563
|
+
const removeGroup = (group) => {
|
|
564
|
+
group.files = group.files.filter(file => !remove(file));
|
|
565
|
+
return group.files.length === 0;
|
|
566
|
+
};
|
|
567
|
+
const cutoff = now.getTime() - LIVE_RETAIN_MS;
|
|
568
|
+
let kept = [...groups.values()].sort((a, b) => a.ageAnchorMs - b.ageAnchorMs).filter(group => group.ageAnchorMs > cutoff || !removeGroup(group));
|
|
569
|
+
// Oldest-first beyond the bound, continuing past any that can't go — still never touching an active run.
|
|
570
|
+
let excess = kept.length - LIVE_FILE_BOUND;
|
|
571
|
+
kept = kept.filter(group => excess <= 0 || !removeGroup(group) || (excess -= 1, false));
|
|
572
|
+
// Then the total, oldest first, counting only what is really still there.
|
|
573
|
+
const bytesOf = (group) => group.files.reduce((sum, file) => sum + file.bytes, 0);
|
|
574
|
+
let total = kept.reduce((sum, group) => sum + bytesOf(group), 0);
|
|
575
|
+
for (const group of kept) {
|
|
576
|
+
if (total <= LIVE_TOTAL_BYTES)
|
|
577
|
+
break;
|
|
578
|
+
const before = bytesOf(group);
|
|
579
|
+
removeGroup(group);
|
|
580
|
+
total -= before - bytesOf(group);
|
|
432
581
|
}
|
|
433
582
|
return { removed };
|
|
434
583
|
}
|
|
@@ -24,8 +24,11 @@ export type OpenRouterModels = {
|
|
|
24
24
|
export declare function parseOpenRouterModels(data: unknown): OpenRouterModel[] | null;
|
|
25
25
|
export declare function fetchOpenRouterModels(key: string | null, fetcher?: typeof fetch): Promise<OpenRouterModels>;
|
|
26
26
|
/** One bounded cache per console. A changed/removed key cannot reuse another account's list. */
|
|
27
|
-
export declare function openRouterModelsCache(fetcher?: typeof fetch): (key: string | null, refresh?: boolean) => Promise<OpenRouterModels
|
|
27
|
+
export declare function openRouterModelsCache(fetcher?: typeof fetch): ((key: string | null, refresh?: boolean) => Promise<OpenRouterModels>) & {
|
|
28
|
+
known: (key: string | null) => OpenRouterModels | null;
|
|
29
|
+
};
|
|
28
30
|
export declare function modelDollars(amount: number | null): string;
|
|
29
|
-
|
|
31
|
+
/** `refreshAction`: where the Refresh models button posts (the picker sits inside a form); a page never fetches. */
|
|
32
|
+
export declare function openRouterPicker(catalog: OpenRouterModels, selected: string | null, refreshAction: string): Html;
|
|
30
33
|
/** Only filters already-rendered options; credentials never enter this script. */
|
|
31
34
|
export declare function openRouterPickerScript(): string;
|
|
@@ -60,7 +60,9 @@ export async function fetchOpenRouterModels(key, fetcher = fetch) {
|
|
|
60
60
|
export function openRouterModelsCache(fetcher = fetch) {
|
|
61
61
|
let cache = null;
|
|
62
62
|
let pending = null;
|
|
63
|
-
|
|
63
|
+
/** The list last loaded for this key, without fetching: what a page shows (docs/plans/get-side-effects.md). */
|
|
64
|
+
const known = (key) => cache?.fingerprint === createHash("sha256").update(key ?? "public").digest("hex") ? cache.result : null;
|
|
65
|
+
return Object.assign(async (key, refresh = false) => {
|
|
64
66
|
const fingerprint = createHash("sha256").update(key ?? "public").digest("hex");
|
|
65
67
|
if (!refresh && cache?.fingerprint === fingerprint && cache.expires > Date.now())
|
|
66
68
|
return cache.result;
|
|
@@ -75,7 +77,7 @@ export function openRouterModelsCache(fetcher = fetch) {
|
|
|
75
77
|
pending = null;
|
|
76
78
|
}
|
|
77
79
|
return value;
|
|
78
|
-
};
|
|
80
|
+
}, { known });
|
|
79
81
|
}
|
|
80
82
|
export function modelDollars(amount) {
|
|
81
83
|
if (amount === null)
|
|
@@ -93,13 +95,14 @@ function details(model) {
|
|
|
93
95
|
(model.conditionalPricing ? "Rates change with context length or other conditions. " : "") +
|
|
94
96
|
(model.extraCharges ? "Additional usage charges may apply. " : "") + "Actual charges depend on routing and usage.";
|
|
95
97
|
}
|
|
96
|
-
|
|
98
|
+
/** `refreshAction`: where the Refresh models button posts (the picker sits inside a form); a page never fetches. */
|
|
99
|
+
export function openRouterPicker(catalog, selected, refreshAction) {
|
|
97
100
|
const rows = catalog.ok ? catalog.models : [];
|
|
98
101
|
const current = rows.find(row => row.id === selected);
|
|
99
102
|
const manual = selected && !current ? html `<option selected value="${selected}" data-detail="This saved model is not in the loaded catalog. Availability and pricing are unverified.">${selected} · Saved choice</option>` : "";
|
|
100
103
|
return html `<div data-openrouter-picker><p class="meta">${catalog.ok
|
|
101
104
|
? `${rows.length} text models · ${catalog.source === "account" ? "Filtered for your OpenRouter account" : "Public catalog · Connect a key to check your account"} · Updated ${catalog.checkedAt.slice(11, 16)} UTC`
|
|
102
|
-
: catalog.problem} <
|
|
105
|
+
: catalog.problem} <button type="submit" class="secondary" formaction="${refreshAction}" formnovalidate>Refresh models</button></p><label data-model-search hidden>Search models<input type="search" placeholder="Search by name or provider" autocomplete="off"></label><label>Model<select name="model" aria-describedby="openrouter-price"><option value="__custom__"${selected === null ? html ` selected` : ""}>Choose a model</option>${manual}${rows.map(row => html `<option value="${row.id}" data-detail="${details(row)}"${row.id === selected ? html ` selected` : ""}>${row.name} · ${modelDollars(row.input)} in / ${modelDollars(row.output)} out per 1M</option>`)}</select></label><p id="openrouter-price" class="meta" aria-live="polite">${current ? details(current) : selected ? "This saved model is not in the loaded catalog. Availability and pricing are unverified." : "Choose a model to see its token prices and context size."}</p><p data-model-count class="meta" aria-live="polite"></p><p class="meta"><a href="https://openrouter.ai/models" target="_blank" rel="noreferrer">Compare models on OpenRouter ↗</a> · Prices in USD; catalog browsing uses no model tokens.</p></div>`;
|
|
103
106
|
}
|
|
104
107
|
/** Only filters already-rendered options; credentials never enter this script. */
|
|
105
108
|
export function openRouterPickerScript() {
|
package/dist/operate.js
CHANGED
|
@@ -3907,6 +3907,8 @@ async function startConsole(options) {
|
|
|
3907
3907
|
const { context } = options;
|
|
3908
3908
|
const server = createDecisionServer({
|
|
3909
3909
|
store: context.store,
|
|
3910
|
+
// Sign-in, integration and model checks, and the tidy-ups no page visit does (docs/plans/get-side-effects.md).
|
|
3911
|
+
backgroundChecks: true,
|
|
3910
3912
|
...(context.desktopIdentity === undefined ? {} : { desktopIdentity: context.desktopIdentity }),
|
|
3911
3913
|
...(context.additionalProjectRepos === undefined || options.currentRepos === undefined ? {} : { additionalProjectRepos: options.currentRepos }),
|
|
3912
3914
|
evidenceRoot: context.evidenceRoot,
|
|
@@ -4072,7 +4074,8 @@ async function requeueTask(positional, flags, context) {
|
|
|
4072
4074
|
* `toolroll task regate <id>` — the approved check again on the last
|
|
4073
4075
|
* attempt's exact commit (v70). A new attempt whose prepared candidate is
|
|
4074
4076
|
* that commit: no agent, a fresh receipt and proof, the ordinary review.
|
|
4075
|
-
* The operator's yes seals the rerun scope; who asked is recorded.
|
|
4077
|
+
* The operator's yes seals the rerun scope; who asked is recorded. A task
|
|
4078
|
+
* that already names a prepared candidate keeps its approved scope as it is.
|
|
4076
4079
|
*/
|
|
4077
4080
|
async function regateTaskCommand(positional, flags, context) {
|
|
4078
4081
|
const { store, write, json, clock } = context;
|
|
@@ -4087,7 +4090,7 @@ async function regateTaskCommand(positional, flags, context) {
|
|
|
4087
4090
|
if (!result.ok)
|
|
4088
4091
|
return fail(write, json, "task regate", result.reason, result.message, EXIT.refused);
|
|
4089
4092
|
return succeed(write, json, "task regate", { id, run: result.run, head: result.head }, () => [
|
|
4090
|
-
`${id} is queued again: the approved check runs on commit ${result.head.slice(0, 7)}
|
|
4093
|
+
`${id} is queued again: the approved check runs on commit ${result.head.slice(0, 7)}, the same files attempt #${result.run} checked, with no agent. A fresh review follows.`,
|
|
4091
4094
|
]);
|
|
4092
4095
|
}
|
|
4093
4096
|
/**
|
package/dist/peek-cli.d.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Read-only by construction: nothing here writes to the store or the
|
|
15
15
|
* files; a peek is a look.
|
|
16
16
|
*/
|
|
17
|
-
import type
|
|
17
|
+
import { type Store } from "./store.js";
|
|
18
18
|
export declare const PEEK_TAIL_LINES = 400;
|
|
19
19
|
export type PeekPane = {
|
|
20
20
|
runId: number;
|
|
@@ -39,14 +39,21 @@ type Tail = {
|
|
|
39
39
|
gen?: string;
|
|
40
40
|
inKey?: boolean;
|
|
41
41
|
keyTail?: string;
|
|
42
|
+
/** The "Show commands" answer the cached lines were made under; absent is unknown (treated as on: never trusted). */
|
|
43
|
+
commands?: boolean;
|
|
42
44
|
};
|
|
43
45
|
/**
|
|
44
46
|
* Advance one run's tail from its offset; `replaced` resets visibly. The
|
|
45
47
|
* offset always moves by the bytes read, even when nothing in them shows
|
|
46
48
|
* (a finished step, a skipped line), and an offset is only ever used
|
|
47
|
-
* against the file it came from.
|
|
49
|
+
* against the file it came from. `commands` is the run's own captured
|
|
50
|
+
* "Show commands" answer (store.ts runLiveCommands), read now: lines cached
|
|
51
|
+
* under any other answer (off, on, or unknown) are dropped and the file is
|
|
52
|
+
* read again from its start under this one, on every path — nothing
|
|
53
|
+
* projected with command detail outlives the answer turning off, and a
|
|
54
|
+
* recovered answer shows the detail again.
|
|
48
55
|
*/
|
|
49
|
-
export declare function advanceTail(root: string, runId: number,
|
|
56
|
+
export declare function advanceTail(root: string, runId: number, previous: Tail, commands: boolean): Tail;
|
|
50
57
|
/** The stage word for a pane: the machine's own phase, or what precedes it. */
|
|
51
58
|
export declare function stageWord(run: {
|
|
52
59
|
phase: string | null;
|
package/dist/peek-cli.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
18
|
import { liveWindowLines, readLiveWindow } from "./live.js";
|
|
19
19
|
import { validKeyTail } from "./live-records.js";
|
|
20
|
+
import { runLiveCommands } from "./store.js";
|
|
20
21
|
import { sleep } from "./sleep.js";
|
|
21
22
|
export const PEEK_TAIL_LINES = 400;
|
|
22
23
|
const ESC = "\u001b";
|
|
@@ -25,21 +26,29 @@ const REPLACED = { offset: 0, lines: ["[the live view was replaced — starting
|
|
|
25
26
|
* Advance one run's tail from its offset; `replaced` resets visibly. The
|
|
26
27
|
* offset always moves by the bytes read, even when nothing in them shows
|
|
27
28
|
* (a finished step, a skipped line), and an offset is only ever used
|
|
28
|
-
* against the file it came from.
|
|
29
|
+
* against the file it came from. `commands` is the run's own captured
|
|
30
|
+
* "Show commands" answer (store.ts runLiveCommands), read now: lines cached
|
|
31
|
+
* under any other answer (off, on, or unknown) are dropped and the file is
|
|
32
|
+
* read again from its start under this one, on every path — nothing
|
|
33
|
+
* projected with command detail outlives the answer turning off, and a
|
|
34
|
+
* recovered answer shows the detail again.
|
|
29
35
|
*/
|
|
30
|
-
export function advanceTail(root, runId,
|
|
36
|
+
export function advanceTail(root, runId, previous, commands) {
|
|
37
|
+
const tail = previous.commands !== commands && (previous.lines.length > 0 || previous.offset > 0)
|
|
38
|
+
? { offset: 0, lines: [], state: "not-yet" }
|
|
39
|
+
: previous;
|
|
31
40
|
const read = readLiveWindow(root, runId, tail.offset);
|
|
32
41
|
if (!read.ok) {
|
|
33
42
|
if (read.reason === "replaced")
|
|
34
|
-
return REPLACED;
|
|
35
|
-
return { ...tail, keyTail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "", state: read.reason === "missing" ? (tail.lines.length === 0 ? "not-yet" : tail.state) : "unreadable" };
|
|
43
|
+
return { ...REPLACED, commands };
|
|
44
|
+
return { ...tail, commands, keyTail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "", state: read.reason === "missing" ? (tail.lines.length === 0 ? "not-yet" : tail.state) : "unreadable" };
|
|
36
45
|
}
|
|
37
46
|
if (tail.gen !== undefined && tail.gen !== read.gen)
|
|
38
|
-
return { ...REPLACED, gen: read.gen };
|
|
47
|
+
return { ...REPLACED, gen: read.gen, commands };
|
|
39
48
|
const keys = { inKey: tail.inKey === true, tail: validKeyTail(tail.keyTail ?? "") ? tail.keyTail ?? "" : "" };
|
|
40
|
-
const fresh = liveWindowLines(read, keys);
|
|
49
|
+
const fresh = liveWindowLines(read, commands, keys);
|
|
41
50
|
const lines = [...tail.lines, ...fresh].slice(-PEEK_TAIL_LINES);
|
|
42
|
-
return { offset: read.nextOffset, lines, state: lines.length === 0 ? "not-yet" : "live", gen: read.gen, inKey: keys.inKey, keyTail: keys.tail };
|
|
51
|
+
return { offset: read.nextOffset, lines, state: lines.length === 0 ? "not-yet" : "live", gen: read.gen, inKey: keys.inKey, keyTail: keys.tail, commands };
|
|
43
52
|
}
|
|
44
53
|
/** The stage word for a pane: the machine's own phase, or what precedes it. */
|
|
45
54
|
export function stageWord(run) {
|
|
@@ -58,7 +67,7 @@ export function snapshotLiveRuns(store, root, now, tails = new Map()) {
|
|
|
58
67
|
const panes = [];
|
|
59
68
|
for (const run of store.liveRuns(now)) {
|
|
60
69
|
const previous = tails.get(run.id) ?? { offset: 0, lines: [], state: "not-yet" };
|
|
61
|
-
const tail = advanceTail(root, run.id, previous);
|
|
70
|
+
const tail = advanceTail(root, run.id, previous, runLiveCommands(store, run.id));
|
|
62
71
|
tails.set(run.id, tail);
|
|
63
72
|
panes.push({
|
|
64
73
|
runId: run.id,
|
package/dist/planner.js
CHANGED
|
@@ -585,7 +585,7 @@ export async function plan(store, request) {
|
|
|
585
585
|
}
|
|
586
586
|
// The live window (peek): the same transcript file the builder keeps,
|
|
587
587
|
// so `toolroll peek` and the run page can watch this session too.
|
|
588
|
-
const liveLog = withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, taskRef, worktree)), store, request.runId, clock);
|
|
588
|
+
const liveLog = withRunActivity(openLiveLog(root, request.runId, liveLogContext(store, request.runId, taskRef, worktree, clock())), store, request.runId, clock);
|
|
589
589
|
// The durable run row, not a caller's duplicate options, is the route
|
|
590
590
|
// every correction inherits.
|
|
591
591
|
const provider = logicalRun.provider;
|