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
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
|
}
|
package/dist/operate.js
CHANGED
|
@@ -4072,7 +4072,8 @@ async function requeueTask(positional, flags, context) {
|
|
|
4072
4072
|
* `toolroll task regate <id>` — the approved check again on the last
|
|
4073
4073
|
* attempt's exact commit (v70). A new attempt whose prepared candidate is
|
|
4074
4074
|
* 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.
|
|
4075
|
+
* The operator's yes seals the rerun scope; who asked is recorded. A task
|
|
4076
|
+
* that already names a prepared candidate keeps its approved scope as it is.
|
|
4076
4077
|
*/
|
|
4077
4078
|
async function regateTaskCommand(positional, flags, context) {
|
|
4078
4079
|
const { store, write, json, clock } = context;
|
|
@@ -4087,7 +4088,7 @@ async function regateTaskCommand(positional, flags, context) {
|
|
|
4087
4088
|
if (!result.ok)
|
|
4088
4089
|
return fail(write, json, "task regate", result.reason, result.message, EXIT.refused);
|
|
4089
4090
|
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)}
|
|
4091
|
+
`${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
4092
|
]);
|
|
4092
4093
|
}
|
|
4093
4094
|
/**
|
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;
|
|
@@ -24,7 +24,9 @@ export type LimitReading = {
|
|
|
24
24
|
billing?: Billing;
|
|
25
25
|
};
|
|
26
26
|
export declare const record: (value: unknown) => Record<string, unknown> | null;
|
|
27
|
-
/** A Claude stream event's limits, or null when it isn't
|
|
27
|
+
/** A Claude stream event's limits, or null when it isn't the session's own `rate_limit_event` or says nothing usable.
|
|
28
|
+
* A subagent's (parent_tool_use_id set) is never read: the session reports its own windows each turn, and a
|
|
29
|
+
* subagent-tagged reading can't be told apart from one shaped by the subagent's own traffic (stream-scope.ts). */
|
|
28
30
|
export declare function claudeLimitsOf(event: unknown): LimitReading | null;
|
|
29
31
|
/** Codex's `account/rateLimits/read` answer as a reading, or null. */
|
|
30
32
|
export declare function codexLimitsOf(result: unknown): LimitReading | null;
|
package/dist/provider-limits.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { topLevelEvent } from "./stream-scope.js";
|
|
1
2
|
const WINDOW_NAME = /^[a-z][a-z0-9_]{0,39}$/;
|
|
2
3
|
const CLAUDE_MINUTES = { five_hour: 300, seven_day: 10_080, seven_day_opus: 10_080, seven_day_sonnet: 10_080 };
|
|
3
4
|
const finite = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
@@ -8,10 +9,12 @@ const epoch = (value) => {
|
|
|
8
9
|
return seconds === null || seconds < 1_000_000_000 || seconds > 10_000_000_000 ? null : new Date(seconds * 1000).toISOString();
|
|
9
10
|
};
|
|
10
11
|
export const record = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
11
|
-
/** A Claude stream event's limits, or null when it isn't
|
|
12
|
+
/** A Claude stream event's limits, or null when it isn't the session's own `rate_limit_event` or says nothing usable.
|
|
13
|
+
* A subagent's (parent_tool_use_id set) is never read: the session reports its own windows each turn, and a
|
|
14
|
+
* subagent-tagged reading can't be told apart from one shaped by the subagent's own traffic (stream-scope.ts). */
|
|
12
15
|
export function claudeLimitsOf(event) {
|
|
13
16
|
const body = record(event);
|
|
14
|
-
if (body === null || body["type"] !== "rate_limit_event")
|
|
17
|
+
if (body === null || body["type"] !== "rate_limit_event" || !topLevelEvent(body))
|
|
15
18
|
return null;
|
|
16
19
|
const info = record(body["rate_limit_info"]);
|
|
17
20
|
if (info === null)
|
package/dist/provider.js
CHANGED
|
@@ -29,6 +29,7 @@ import { REVIEW_OUTPUT_LIMITS } from "./structured-output.js";
|
|
|
29
29
|
import { ACP_AGENTS, isAcpProvider, withAcp } from "./acp-agents.js";
|
|
30
30
|
import { acpParse } from "./acp-client.js";
|
|
31
31
|
import { agentSpecSchema, MODEL_ID, modelIdSchema, PROVIDER_ID_VALUES, providerIdSchema } from "./contracts/provider.js";
|
|
32
|
+
import { primaryResultEvent, topLevelEvent } from "./stream-scope.js";
|
|
32
33
|
export const PROVIDER_IDS = PROVIDER_ID_VALUES;
|
|
33
34
|
export function isProviderId(value) {
|
|
34
35
|
return providerIdSchema.safeParse(value).success;
|
|
@@ -292,17 +293,6 @@ function claudeFinalMessage(result) {
|
|
|
292
293
|
}
|
|
293
294
|
return typeof result.result === "string" ? result.result : null;
|
|
294
295
|
}
|
|
295
|
-
/**
|
|
296
|
-
* The primary-result allowlist (arc 1 finding 10): only an absent origin or
|
|
297
|
-
* an explicit human origin can be the main query's accounting envelope.
|
|
298
|
-
* Every other kind — task-notification, channel, peer, coordinator, and
|
|
299
|
-
* anything the SDK grows later — fails closed as a non-primary result.
|
|
300
|
-
*/
|
|
301
|
-
function claudePrimaryOrigin(event) {
|
|
302
|
-
if (event.origin === undefined || event.origin === null)
|
|
303
|
-
return true;
|
|
304
|
-
return typeof event.origin === "object" && String(event.origin["kind"] ?? "") === "human";
|
|
305
|
-
}
|
|
306
296
|
function claudeEnvelopeOf(result, sessionFromInit, initObserved, initSessionConflict = false) {
|
|
307
297
|
const input = result?.usage?.input_tokens;
|
|
308
298
|
const output = result?.usage?.output_tokens;
|
|
@@ -378,6 +368,10 @@ function claudeParse(stdout) {
|
|
|
378
368
|
}
|
|
379
369
|
if (event === null || typeof event !== "object")
|
|
380
370
|
continue;
|
|
371
|
+
// A subagent's object (parent_tool_use_id set) is never the session's envelope, init, result or overflow witness
|
|
372
|
+
// (stream-scope.ts). The transport's own witness carries no parent and still counts.
|
|
373
|
+
if (!topLevelEvent(event))
|
|
374
|
+
continue;
|
|
381
375
|
const type = event["type"];
|
|
382
376
|
if (type === undefined) {
|
|
383
377
|
// Legacy buffered envelope: one JSON object, no event framing.
|
|
@@ -404,7 +398,7 @@ function claudeParse(stdout) {
|
|
|
404
398
|
// replacement for the primary result's usage/accounting envelope.
|
|
405
399
|
transportFailure = result;
|
|
406
400
|
}
|
|
407
|
-
if (primary === null &&
|
|
401
|
+
if (primary === null && primaryResultEvent(event))
|
|
408
402
|
primary = result;
|
|
409
403
|
}
|
|
410
404
|
}
|
package/dist/release-adapters.js
CHANGED
|
@@ -115,6 +115,9 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
115
115
|
const failed = newest !== undefined && newest["finishedAt"] != null && newest["outcome"] !== "built" && newest["outcome"] !== "no-change" && Number(newest["id"]) !== resultRun
|
|
116
116
|
? `run ${String(newest["id"])} ended ${String(newest["outcome"] ?? "without an outcome")}` : null;
|
|
117
117
|
const worktree = runs.find(run => run["id"] === resultRun)?.["worktree"];
|
|
118
|
+
// Queued again or still building past the result shown (a regate): that result is the one before the rerun.
|
|
119
|
+
const state = object(answer["task"])["state"];
|
|
120
|
+
const pending = resultRun !== null && (state === "queued" || state === "running" || (newest !== undefined && newest["finishedAt"] == null && Number(newest["id"]) > resultRun));
|
|
118
121
|
return {
|
|
119
122
|
exists: true,
|
|
120
123
|
candidate: textOr(scope["candidate"]),
|
|
@@ -131,6 +134,7 @@ export function gateStateOf(answer, accounts = []) {
|
|
|
131
134
|
: checks["status"] === "passed" && checks["level"] !== "full" ? "not-full" : String(checks["status"] ?? "not-run"),
|
|
132
135
|
},
|
|
133
136
|
failed,
|
|
137
|
+
pending,
|
|
134
138
|
completion: textOr(completion["digest"]) === null ? null : { actor: String(completion["actor"]), at: String(completion["at"]), digest: String(completion["digest"]) },
|
|
135
139
|
};
|
|
136
140
|
}
|
package/dist/release.d.ts
CHANGED
|
@@ -90,6 +90,9 @@ export type GateState = {
|
|
|
90
90
|
} | null;
|
|
91
91
|
/** A finished build that left no result to complete (it failed before the check), in words. */
|
|
92
92
|
failed: string | null;
|
|
93
|
+
/** A newer attempt is queued or running (`task regate` after a failed check): the result shown is the one before it.
|
|
94
|
+
* Absent from a plane too old to say. */
|
|
95
|
+
pending?: boolean;
|
|
93
96
|
completion: {
|
|
94
97
|
actor: string;
|
|
95
98
|
at: string;
|
package/dist/release.js
CHANGED
|
@@ -535,6 +535,9 @@ export async function runRelease(options, adapters) {
|
|
|
535
535
|
await watch("dispatch");
|
|
536
536
|
const gate = await adapters.toolroll.gate(j.task.id);
|
|
537
537
|
await approved(gate);
|
|
538
|
+
// A rerun of the same approved commit is on its way: the failure before it is not this release's answer.
|
|
539
|
+
if (gate.pending === true && gate.result?.check !== "passed")
|
|
540
|
+
return null;
|
|
538
541
|
if (gate.failed !== null)
|
|
539
542
|
throw new Stop("check-failed", `The release build of ${j.task.id} failed: ${gate.failed}. Fix it and release the new commit with --new.`);
|
|
540
543
|
if (gate.result === null || gate.result.check === "running" || gate.result.check === "pending")
|