toolroll 0.9.72 → 0.9.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +108 -0
- package/THIRD_PARTY_NOTICES.md +40 -0
- package/dist/agent-fence.d.ts +5 -0
- package/dist/agent-fence.js +42 -7
- 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-actions.js +13 -8
- package/dist/chat-continuity.js +10 -0
- package/dist/chat-decide.d.ts +47 -1
- package/dist/chat-decide.js +170 -4
- package/dist/chat-quiet.js +3 -3
- package/dist/check-report.js +2 -1
- package/dist/coding-provider.js +4 -2
- package/dist/commit-extract.d.ts +17 -0
- package/dist/commit-extract.js +82 -4
- package/dist/contracts/console-api.d.ts +3 -3
- package/dist/contracts/console-api.js +4 -4
- package/dist/contracts/release-terms.d.ts +7 -1
- package/dist/contracts/release-terms.js +6 -2
- package/dist/deploy-gate.js +5 -2
- package/dist/desktop-update.d.ts +1 -1
- package/dist/dispose.js +35 -2
- package/dist/exec.d.ts +1 -1
- package/dist/exec.js +20 -19
- package/dist/first-run.d.ts +17 -1
- package/dist/first-run.js +38 -0
- package/dist/flow-gallery-ui.js +4 -2
- package/dist/flow-gallery.d.ts +3 -0
- package/dist/flow-gallery.js +6 -2
- package/dist/flow-triggers.d.ts +8 -0
- package/dist/flow-triggers.js +15 -0
- package/dist/flows-cli.js +13 -3
- package/dist/guides.js +10 -1
- package/dist/invoke.d.ts +13 -0
- package/dist/invoke.js +21 -12
- package/dist/kits-ui.js +4 -2
- package/dist/kits.d.ts +6 -4
- package/dist/kits.js +9 -11
- 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/mcp-connect.d.ts +10 -0
- package/dist/mcp-connect.js +2 -0
- 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/project-tools.js +3 -1
- package/dist/protected-storage.d.ts +67 -0
- package/dist/protected-storage.js +398 -0
- 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.d.ts +8 -1
- package/dist/release-adapters.js +84 -14
- package/dist/release-authority.d.ts +6 -0
- package/dist/release-authority.js +9 -0
- package/dist/release-card.d.ts +76 -0
- package/dist/release-card.js +252 -0
- package/dist/release-carry.d.ts +3 -0
- package/dist/release-carry.js +23 -4
- package/dist/release-cli.js +36 -3
- package/dist/release-git.d.ts +43 -0
- package/dist/release-git.js +106 -0
- package/dist/release.d.ts +11 -2
- package/dist/release.js +82 -6
- 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/scope.d.ts +7 -0
- package/dist/scope.js +22 -5
- 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/flows.js +40 -10
- 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-settings.js +1 -1
- 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 +29 -8
- package/dist/server/task-views.js +1 -1
- package/dist/server/tasks.js +59 -41
- package/dist/setup-guide.d.ts +2 -2
- package/dist/store.d.ts +34 -1
- package/dist/store.js +81 -2
- package/dist/stream-scope.d.ts +18 -0
- package/dist/stream-scope.js +28 -0
- package/dist/subagent-memory.d.ts +1 -0
- package/dist/subagent-memory.js +1 -1
- package/dist/subagent-tools.d.ts +22 -6
- package/dist/subagent-tools.js +44 -8
- package/dist/subagent-work.d.ts +2 -0
- package/dist/subagent-work.js +5 -2
- package/dist/subagents-ui.js +6 -4
- package/dist/subscription-chat.js +5 -3
- package/dist/telegram.d.ts +6 -1
- package/dist/telegram.js +58 -6
- 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/mcp-connect.d.ts
CHANGED
|
@@ -117,6 +117,14 @@ export type ConnectVisit = {
|
|
|
117
117
|
scope?: string | null;
|
|
118
118
|
/** Where the service revokes a token, when its metadata said: a run's own token is revoked there when the run ends. */
|
|
119
119
|
revoke?: string | null;
|
|
120
|
+
/** The signer's account generation when the password was typed at Connect: a rotation or revocation since ends the visit. */
|
|
121
|
+
generation?: number;
|
|
122
|
+
/** From a kit: the one grant the password at Connect proved (its subagent, this tool, this project); nothing else. */
|
|
123
|
+
grant?: {
|
|
124
|
+
subagent: number;
|
|
125
|
+
tool: string;
|
|
126
|
+
repo: string;
|
|
127
|
+
} | null;
|
|
120
128
|
};
|
|
121
129
|
/** A server's sign-in: the resource metadata its 401 names (or the well-known one), then its authorization server's metadata. */
|
|
122
130
|
export declare function discoverSignIn(mcpUrl: string, fetcher?: Fetch): Promise<Server | null>;
|
|
@@ -154,6 +162,8 @@ export declare function finishConnect(store: Store, visit: ConnectVisit, code: s
|
|
|
154
162
|
home?: string | undefined;
|
|
155
163
|
test?: boolean;
|
|
156
164
|
omitEnv?: readonly string[];
|
|
165
|
+
/** The signer's standing, checked again after the token answer and before anything changes. */
|
|
166
|
+
stillStanding?: () => boolean;
|
|
157
167
|
}): Promise<{
|
|
158
168
|
ok: true;
|
|
159
169
|
said: string;
|
package/dist/mcp-connect.js
CHANGED
|
@@ -351,6 +351,8 @@ export async function finishConnect(store, visit, code, now, options = {}) {
|
|
|
351
351
|
const tokens = tokensFrom(body, now.getTime());
|
|
352
352
|
if (tokens.access === null)
|
|
353
353
|
return { ok: false, said: `${service.label} didn't send a sign-in token.` };
|
|
354
|
+
if (options.stillStanding !== undefined && !options.stillStanding())
|
|
355
|
+
return { ok: false, said: "Your access changed during the sign-in, so nothing was connected. Connect again." };
|
|
354
356
|
if (had === undefined) {
|
|
355
357
|
const added = addToolTo(store, visit.repo, spec, `${service.label}, connected by signing in`, visit.by, now, { home });
|
|
356
358
|
if (!added.ok)
|
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;
|
package/dist/project-tools.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { toolRefusal } from "./policy.js";
|
|
23
23
|
import { createHash } from "node:crypto";
|
|
24
24
|
import { spawn } from "node:child_process";
|
|
25
|
+
import { isProtectedEnvName } from "./protected-storage.js";
|
|
25
26
|
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
27
|
import { homedir, tmpdir } from "node:os";
|
|
27
28
|
import { realpathSync } from "node:fs";
|
|
@@ -572,7 +573,8 @@ function exchangeStdio(spec, values, timeoutMs, base, omit, request, fence) {
|
|
|
572
573
|
let child;
|
|
573
574
|
try {
|
|
574
575
|
const launch = confined(spec.command, spec.args, { ...(fence ?? {}), temp: env["TMPDIR"] });
|
|
575
|
-
|
|
576
|
+
// A fenced server gets no chat or vault variable (protected-storage.ts) but the tool's own secrets, set by name.
|
|
577
|
+
child = spawn(launch.file, launch.args, { env: fence === null ? { ...env, ...launch.env } : { ...Object.fromEntries(Object.entries(env).filter(([name]) => !isProtectedEnvName(name) || Object.hasOwn(values, name))), ...launch.env }, stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
|
|
576
578
|
}
|
|
577
579
|
catch (error) {
|
|
578
580
|
if (error instanceof ContainmentRefusal) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** One app's storage, relative to the home folder, per platform. */
|
|
2
|
+
export type ProtectedApp = {
|
|
3
|
+
app: string;
|
|
4
|
+
darwin: readonly string[];
|
|
5
|
+
linux: readonly string[];
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Every listed root. Checked against a real Mac (2026-10-25): existence only, never a file's contents. The 1Password
|
|
9
|
+
* containers and the extra Chrome, Edge and other browsers' folders below were found there and added.
|
|
10
|
+
*/
|
|
11
|
+
export declare const PROTECTED_STORAGE: readonly ProtectedApp[];
|
|
12
|
+
/** The chat and vault variables no launch gets. */
|
|
13
|
+
export declare const PROTECTED_ENV_PREFIXES: readonly string[];
|
|
14
|
+
export declare function isProtectedEnvName(name: string): boolean;
|
|
15
|
+
/** `env` without a chat or vault variable. */
|
|
16
|
+
export declare function withoutProtectedEnv<T extends Record<string, string | undefined>>(env: T): T;
|
|
17
|
+
/** How far the link walk goes in each present root. */
|
|
18
|
+
export declare const PROTECTED_WALK: {
|
|
19
|
+
readonly depth: 4;
|
|
20
|
+
readonly entries: 5000;
|
|
21
|
+
};
|
|
22
|
+
/** The roots by name, under the (real) home folder and the overrides the plane's environment names. */
|
|
23
|
+
export declare function protectedStorageNames(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Every protected path one launch denies: each root by name and by real path, every link inside a present root that
|
|
26
|
+
* leads outward (where it leads, by real path and as spelled), and the overrides. Resolved at each launch, never kept.
|
|
27
|
+
* A link macOS makes in a sandboxed app's container to the same place under the home folder (its Desktop, Downloads,
|
|
28
|
+
* Fonts…) is the system's own mirror of the person's folders, not the app's data, and isn't followed (systemMirror).
|
|
29
|
+
*
|
|
30
|
+
* On a Mac, listing inside another app's container waits for the person to answer macOS's "access data from other apps"
|
|
31
|
+
* prompt when the process asking has no answer yet (a plane run by launchd: every launch, and the console with it, hung
|
|
32
|
+
* there in release check 3249). So the containers are walked only after a time-limited look in a child process
|
|
33
|
+
* (`containers`, containersListable) shows they can be listed without waiting. A look that fails any other way refuses
|
|
34
|
+
* the launch (it throws). When macOS is waiting on the person, the containers stay denied by name and real path, and
|
|
35
|
+
* the outward links the last complete walk found (kept in the plane's own state folder, linkCacheFile) stay denied too;
|
|
36
|
+
* with none kept, only the roots are denied, and that is said once.
|
|
37
|
+
*/
|
|
38
|
+
export declare function protectedStorageDenies(home?: string, env?: Readonly<Record<string, string | undefined>>, platform?: NodeJS.Platform, say?: (line: string) => void, containers?: (roots: readonly string[]) => boolean, list?: (path: string) => import("node:fs").Dirent[]): string[];
|
|
39
|
+
/** How long the look at other apps' containers may take before Toolroll takes it that macOS is waiting on the person. */
|
|
40
|
+
export declare const CONTAINER_LOOK_MS = 3000;
|
|
41
|
+
/** What a look at the containers found: listable without waiting, macOS waiting on the person (the look timed out), or
|
|
42
|
+
* the look itself failing (it couldn't start, crashed or was killed by anything but its own limit). */
|
|
43
|
+
export type ContainerLook = "listable" | "waiting" | "failed";
|
|
44
|
+
/**
|
|
45
|
+
* Whether this process can list inside these app containers without macOS stopping to ask the person: the walk run
|
|
46
|
+
* first in a child process that is killed at CONTAINER_LOOK_MS. Finishing in time (listed, or refused outright) means
|
|
47
|
+
* the walk can run here too; only running into that limit means macOS is waiting on the person (only the child waited).
|
|
48
|
+
* A look that fails any other way is tried once more, then throws: the launch is refused rather than fenced with less.
|
|
49
|
+
* A yes is kept (macOS keeps the person's answer); a no is looked at again after CONTAINER_RELOOK_MS.
|
|
50
|
+
*/
|
|
51
|
+
export declare function containersListable(roots: readonly string[], look?: (roots: readonly string[]) => ContainerLook, now?: () => number): boolean;
|
|
52
|
+
/** A look's outcome from its child's result: only its own limit (ETIMEDOUT) is macOS waiting; any other error, signal
|
|
53
|
+
* or exit code is a failed look. */
|
|
54
|
+
export declare function lookOutcome(result: {
|
|
55
|
+
error?: Error;
|
|
56
|
+
signal: NodeJS.Signals | null;
|
|
57
|
+
status: number | null;
|
|
58
|
+
}): ContainerLook;
|
|
59
|
+
/** Where the outward links the last complete container walk found are kept: the plane's own state folder (fenced from
|
|
60
|
+
* every build), one file per user. */
|
|
61
|
+
export declare function linkCacheFile(home: string, env?: Readonly<Record<string, string | undefined>>): string;
|
|
62
|
+
/** The kept links for this home: only a plain file of this user's (no link), at most LINK_CACHE_MAX, of exactly the
|
|
63
|
+
* kept shape; anything else is no cache at all. */
|
|
64
|
+
export declare function readLinkCache(file: string, home: string): string[] | null;
|
|
65
|
+
/** Keep a complete walk's links: written aside (owner-only) and renamed into place, only into a state folder that is
|
|
66
|
+
* already there. A failed write keeps whatever was kept before. */
|
|
67
|
+
export declare function writeLinkCache(file: string, home: string, targets: readonly string[]): void;
|