toolroll 0.9.60 → 0.9.61
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 +15 -0
- package/dist/agent-board.d.ts +208 -0
- package/dist/agent-board.js +454 -0
- package/dist/agent-fence.d.ts +19 -0
- package/dist/agent-fence.js +100 -8
- package/dist/agent-pulse.d.ts +43 -0
- package/dist/agent-pulse.js +214 -0
- package/dist/agent-turns.d.ts +52 -0
- package/dist/agent-turns.js +137 -0
- package/dist/agents-board.d.ts +101 -0
- package/dist/agents-board.js +225 -0
- package/dist/assignment-adapters.d.ts +1 -0
- package/dist/assignment-status.d.ts +1 -1
- package/dist/assignment.d.ts +4 -0
- package/dist/assignment.js +8 -6
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +29 -25
- package/dist/browser-crew.js +2 -0
- package/dist/browser-workspace.d.ts +79 -1
- package/dist/browser-workspace.js +1 -0
- package/dist/builder.d.ts +8 -0
- package/dist/builder.js +113 -13
- package/dist/chat-channel.js +9 -0
- package/dist/chat-core.d.ts +6 -0
- package/dist/chat-core.js +8 -0
- package/dist/chat-delivery.js +22 -0
- package/dist/chat-messages.js +8 -1
- package/dist/chat-quiet.js +1 -0
- package/dist/chat-voice.d.ts +2 -0
- package/dist/chat-voice.js +2 -0
- package/dist/checkout-cleanup.d.ts +3 -0
- package/dist/checkout-cleanup.js +11 -2
- package/dist/contracts/console-api.d.ts +3 -1
- package/dist/contracts/console-api.js +5 -1
- package/dist/contracts/lead-tools.d.ts +16 -2
- package/dist/contracts/lead-tools.js +5 -1
- package/dist/desktop-update.d.ts +1 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +6 -0
- package/dist/evidence.js +14 -3
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/invoke.js +110 -18
- package/dist/landed-cleanup.d.ts +157 -0
- package/dist/landed-cleanup.js +707 -0
- package/dist/lead-cli.js +22 -19
- package/dist/lead-contract.js +1 -1
- package/dist/lead-doors.js +45 -1
- package/dist/lead-tools.js +40 -7
- package/dist/live-input.d.ts +68 -0
- package/dist/live-input.js +254 -0
- package/dist/live.d.ts +1 -1
- package/dist/live.js +4 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/operate-remote-arguments.js +5 -1
- package/dist/operate-remote.js +1 -1
- package/dist/operate.d.ts +1 -1
- package/dist/operate.js +188 -16
- package/dist/orphan-sweep.d.ts +2 -0
- package/dist/orphan-sweep.js +5 -3
- package/dist/project-delete-ui.d.ts +11 -0
- package/dist/project-delete-ui.js +29 -2
- package/dist/project-tools.d.ts +7 -11
- package/dist/project-tools.js +13 -37
- package/dist/provider.d.ts +65 -8
- package/dist/provider.js +80 -21
- package/dist/result-completion.d.ts +7 -0
- package/dist/result-completion.js +9 -0
- package/dist/runner.d.ts +4 -2
- package/dist/runner.js +3 -3
- package/dist/serve.js +19 -1
- package/dist/server/live.d.ts +3 -0
- package/dist/server/live.js +35 -2
- package/dist/server/pages.js +59 -35
- package/dist/server/render-chat.js +12 -0
- package/dist/server/render-tasks.d.ts +12 -2
- package/dist/server/render-tasks.js +27 -7
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +9 -0
- package/dist/server/tasks.js +63 -0
- package/dist/shared-deps.d.ts +9 -4
- package/dist/shared-deps.js +89 -13
- package/dist/staged-runtimes.d.ts +16 -4
- package/dist/staged-runtimes.js +160 -38
- package/dist/steer-live.d.ts +19 -0
- package/dist/steer-live.js +93 -0
- package/dist/steer-status.d.ts +8 -2
- package/dist/steer-status.js +19 -5
- package/dist/storage-sweep-child.d.ts +1 -0
- package/dist/storage-sweep-child.js +76 -0
- package/dist/storage-sweep-process.d.ts +41 -0
- package/dist/storage-sweep-process.js +187 -0
- package/dist/storage-sweep.d.ts +30 -6
- package/dist/storage-sweep.js +214 -28
- package/dist/storage-ui.d.ts +2 -0
- package/dist/storage-ui.js +16 -4
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +35 -0
- package/dist/store.d.ts +163 -2
- package/dist/store.js +350 -15
- package/dist/surface.js +4 -2
- package/dist/sweep-quarantine.d.ts +28 -0
- package/dist/sweep-quarantine.js +153 -0
- package/dist/task-status.d.ts +2 -0
- package/dist/task-status.js +5 -1
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +3 -2
- package/dist/work-index.d.ts +1 -0
- package/dist/work-index.js +32 -12
- package/dist/work-summary.d.ts +2 -2
- package/dist/workspace-revision.js +3 -1
- package/dist/worktree.d.ts +3 -1
- package/dist/worktree.js +93 -10
- package/package.json +1 -1
- package/scripts/provider-canary.mjs +235 -147
package/dist/shared-deps.js
CHANGED
|
@@ -17,10 +17,12 @@
|
|
|
17
17
|
* checkout links or matches; the plane's daily storage sweep removes one no checkout has used for a week.
|
|
18
18
|
*/
|
|
19
19
|
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
-
import { spawnSync } from "node:child_process";
|
|
20
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
21
|
+
import { rm } from "node:fs/promises";
|
|
21
22
|
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
|
22
23
|
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
23
24
|
import { markNeverIndex } from "./never-index.js";
|
|
25
|
+
import { QUARANTINE_NOTE, quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantine } from "./sweep-quarantine.js";
|
|
24
26
|
export const DEPS_FOLDER = "deps";
|
|
25
27
|
const READY = "ready.json";
|
|
26
28
|
const RETIRED = "retired";
|
|
@@ -199,7 +201,11 @@ function treeDigest(nodeModules) {
|
|
|
199
201
|
}
|
|
200
202
|
/** The shared node_modules for a key, when one is ready and unchanged since it was made. A changed one is retired. */
|
|
201
203
|
export function readyCopy(root, key) {
|
|
204
|
+
if (!/^[0-9a-f]{24}$/.test(key))
|
|
205
|
+
return null;
|
|
202
206
|
const dir = join(root, key);
|
|
207
|
+
if (existsSync(join(dir, QUARANTINE_NOTE)))
|
|
208
|
+
return null;
|
|
203
209
|
const ready = readReady(dir);
|
|
204
210
|
if (ready === null || ready.key !== key || existsSync(join(dir, RETIRED)) || !existsSync(join(dir, NODE_MODULES)))
|
|
205
211
|
return null;
|
|
@@ -259,12 +265,20 @@ export function linkInto(worktree, shared) {
|
|
|
259
265
|
const at = join(worktree, NODE_MODULES);
|
|
260
266
|
if (existsSync(at) || isLink(at))
|
|
261
267
|
return null;
|
|
268
|
+
const root = dirname(dirname(shared)), key = basename(dirname(shared));
|
|
269
|
+
if (readyCopy(root, key) !== shared)
|
|
270
|
+
return null;
|
|
262
271
|
try {
|
|
263
272
|
mkdirSync(at);
|
|
273
|
+
// Publish intent before reading the copy. A sweep that renames it mid-link sees this user and restores it;
|
|
274
|
+
// if the sweep won before the intent, the final ready check fails and no broken link is handed to a run.
|
|
275
|
+
writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
|
|
276
|
+
touch(dirname(shared));
|
|
264
277
|
for (const entry of readdirSync(shared, { withFileTypes: true })) {
|
|
265
278
|
symlinkSync(join(shared, entry.name), join(at, entry.name), entry.isDirectory() ? (process.platform === "win32" ? "junction" : "dir") : "file");
|
|
266
279
|
}
|
|
267
|
-
|
|
280
|
+
if (readyCopy(root, key) !== shared)
|
|
281
|
+
throw new Error("Shared copy is no longer available");
|
|
268
282
|
markNeverIndex(at);
|
|
269
283
|
touch(dirname(shared));
|
|
270
284
|
return "link";
|
|
@@ -272,6 +286,8 @@ export function linkInto(worktree, shared) {
|
|
|
272
286
|
catch {
|
|
273
287
|
rmSync(at, { recursive: true, force: true });
|
|
274
288
|
}
|
|
289
|
+
if (readyCopy(root, key) !== shared)
|
|
290
|
+
return null;
|
|
275
291
|
const cloned = process.platform === "darwin" ? spawnSync("cp", ["-c", "-R", shared, at], { timeout: 600_000 })
|
|
276
292
|
: process.platform === "linux" ? spawnSync("cp", ["--reflink=always", "-R", shared, at], { timeout: 600_000 })
|
|
277
293
|
: null;
|
|
@@ -396,7 +412,9 @@ export function sharedCopies(root) {
|
|
|
396
412
|
}
|
|
397
413
|
/** Which checkouts use each copy: those linking it, and those whose lockfile it was installed from. */
|
|
398
414
|
export function sharedUse(root, checkouts) {
|
|
399
|
-
|
|
415
|
+
return useCopies(root, sharedCopies(root), checkouts);
|
|
416
|
+
}
|
|
417
|
+
function useCopies(root, copies, checkouts) {
|
|
400
418
|
const users = new Map(copies.map(one => [one.key, new Set()]));
|
|
401
419
|
for (const checkout of checkouts) {
|
|
402
420
|
const linked = linkedKey(checkout.path, root);
|
|
@@ -411,7 +429,8 @@ export function sharedUse(root, checkouts) {
|
|
|
411
429
|
continue;
|
|
412
430
|
for (const copy of copies) {
|
|
413
431
|
const ready = readReady(copy.dir);
|
|
414
|
-
if (!copy.retired && copy.repo === checkout.repo && ready !== null
|
|
432
|
+
if (!copy.retired && copy.repo === checkout.repo && (ready !== null ? matchesInputs(copy.dir, ready, inputs) :
|
|
433
|
+
[inputs.digest, digestPair(inputs.lock, inputs.manifest)].includes(copy.lock)))
|
|
415
434
|
users.get(copy.key).add(checkout.path);
|
|
416
435
|
}
|
|
417
436
|
}
|
|
@@ -456,24 +475,81 @@ export function removeShared(path) {
|
|
|
456
475
|
}
|
|
457
476
|
/**
|
|
458
477
|
* The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
|
|
459
|
-
* Right before a copy goes, who uses it
|
|
460
|
-
* between stays.
|
|
478
|
+
* Right before a copy goes, who uses it, when it was last linked and whether live work uses it (`spare`,
|
|
479
|
+
* storage-sweep.ts) are read again; a copy a checkout picked up in between stays. Removal never holds the event loop.
|
|
461
480
|
*/
|
|
462
|
-
export function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS) {
|
|
481
|
+
export async function pruneUnusedShared(root, checkouts, now, unusedMs = SHARED_UNUSED_MS, options = {}) {
|
|
463
482
|
const stale = (one) => one.checkouts.length === 0 && now.getTime() - Date.parse(one.usedAt) > unusedMs;
|
|
464
483
|
const removed = [];
|
|
465
484
|
const failed = [];
|
|
485
|
+
const finish = async (trash) => {
|
|
486
|
+
try {
|
|
487
|
+
await options.quarantined?.(trash.original, trash.path);
|
|
488
|
+
let used = options.spare?.(trash.original) === true || options.spare?.(trash.path) === true;
|
|
489
|
+
const copy = trash.journal.data;
|
|
490
|
+
if (copy !== null) {
|
|
491
|
+
let usedAt = copy.usedAt;
|
|
492
|
+
try {
|
|
493
|
+
usedAt = statSync(join(trash.path, READY)).mtime.toISOString();
|
|
494
|
+
}
|
|
495
|
+
catch { /* partially deleted trash */ }
|
|
496
|
+
const fresh = useCopies(root, [{ ...copy, dir: trash.path, usedAt }], checkouts())[0];
|
|
497
|
+
used ||= !stale(fresh);
|
|
498
|
+
}
|
|
499
|
+
if (used) {
|
|
500
|
+
if (!restoreQuarantine(trash))
|
|
501
|
+
failed.push(trash.original);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
await deleteQuarantine(trash, options.remove ?? removeSharedLater);
|
|
505
|
+
if (copy !== null)
|
|
506
|
+
removed.push(copy);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
failed.push(trash.original);
|
|
510
|
+
}
|
|
511
|
+
finally {
|
|
512
|
+
releaseQuarantine(trash);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
for (const entry of quarantineEntries(root)) {
|
|
516
|
+
const claimed = claimQuarantine(entry);
|
|
517
|
+
if (claimed !== null)
|
|
518
|
+
await finish(claimed);
|
|
519
|
+
}
|
|
466
520
|
for (const one of sharedUse(root, checkouts()).filter(stale)) {
|
|
521
|
+
await nextTurn();
|
|
467
522
|
const fresh = sharedUse(root, checkouts()).find(other => other.key === one.key);
|
|
468
|
-
if (fresh === undefined || !stale(fresh))
|
|
523
|
+
if (fresh === undefined || !stale(fresh) || options.spare?.(fresh.dir) === true)
|
|
469
524
|
continue;
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
525
|
+
try {
|
|
526
|
+
const trash = quarantine(fresh.dir, fresh);
|
|
527
|
+
if (trash !== null)
|
|
528
|
+
await finish(trash);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
473
531
|
failed.push(fresh.dir);
|
|
532
|
+
}
|
|
474
533
|
}
|
|
475
|
-
for (const debris of sharedDebris(root, now))
|
|
476
|
-
|
|
534
|
+
for (const debris of sharedDebris(root, now)) {
|
|
535
|
+
await nextTurn();
|
|
536
|
+
if (options.spare?.(debris) === true)
|
|
537
|
+
continue;
|
|
538
|
+
try {
|
|
539
|
+
const trash = quarantine(debris, null);
|
|
540
|
+
if (trash !== null)
|
|
541
|
+
await finish(trash);
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
477
544
|
failed.push(debris);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
478
547
|
return { removed, failed };
|
|
479
548
|
}
|
|
549
|
+
const nextTurn = () => new Promise(resolve => setImmediate(resolve));
|
|
550
|
+
/** removeShared without holding the event loop: chmod and rm run off it. */
|
|
551
|
+
async function removeSharedLater(path) {
|
|
552
|
+
if (process.platform !== "win32")
|
|
553
|
+
await new Promise(resolve => execFile("chmod", ["-R", "u+w", path], { timeout: 120_000 }, () => resolve()));
|
|
554
|
+
await rm(path, { recursive: true, force: true });
|
|
555
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ProcessRow } from "./run-closeout.js";
|
|
1
2
|
/** A deploy or update that never finished keeps its stage this long (it may be resumed). */
|
|
2
3
|
export declare const UNFINISHED_KEEP_MS: number;
|
|
3
4
|
export declare const STAGED_FOLDER = "staged-upgrades";
|
|
@@ -22,9 +23,17 @@ export type StagePlan = {
|
|
|
22
23
|
export declare function stagedRuntimes(stateDir: string): Stage[];
|
|
23
24
|
/**
|
|
24
25
|
* Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
|
|
25
|
-
* `standing-orders`) command on PATH or under nvm
|
|
26
|
+
* `standing-orders`) command on PATH, in the usual link folders or under nvm (a link counts by its target), this
|
|
27
|
+
* process, and `extra`.
|
|
26
28
|
*/
|
|
27
|
-
export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string): string[];
|
|
29
|
+
export declare function runtimesInUse(extra?: readonly string[], home?: string, path?: string, stateDir?: string): string[];
|
|
30
|
+
/** Retain only recognized Toolroll entry scripts in candidate stages, never complete argv. ps is asked only
|
|
31
|
+
* for Node/Toolroll processes; /proc supplies NUL-separated argv on Linux. Fail closed with a fixed message,
|
|
32
|
+
* since an execFileSync exception can otherwise contain private argument text in stdout/stderr. */
|
|
33
|
+
export declare function runtimeScriptPaths(stateDir: string, onlyPids?: readonly number[], observations?: {
|
|
34
|
+
census?: () => ProcessRow[];
|
|
35
|
+
argv?: (pids: readonly number[]) => string[];
|
|
36
|
+
}): string[];
|
|
28
37
|
/** What stays and what goes now, and why. */
|
|
29
38
|
export declare function stagePlan(stateDir: string, inUse: readonly string[], now: Date): StagePlan;
|
|
30
39
|
export type StagePrune = {
|
|
@@ -33,10 +42,13 @@ export type StagePrune = {
|
|
|
33
42
|
refused: string | null;
|
|
34
43
|
};
|
|
35
44
|
/**
|
|
36
|
-
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now)
|
|
37
|
-
* still be a real folder directly in staged-upgrades.
|
|
45
|
+
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now), nothing live may use
|
|
46
|
+
* it (`spare`, storage-sweep.ts) and the stage must still be a real folder directly in staged-upgrades.
|
|
38
47
|
*/
|
|
39
48
|
export declare function pruneStagedRuntimes(stateDir: string, now: Date, options?: {
|
|
40
49
|
inUse?: () => string[];
|
|
41
50
|
remove?: (path: string) => void;
|
|
51
|
+
spare?: (path: string) => boolean;
|
|
52
|
+
/** Deterministic race seam, not supplied by production callers. */
|
|
53
|
+
quarantined?: (original: string, trash: string) => void;
|
|
42
54
|
}): StagePrune;
|
package/dist/staged-runtimes.js
CHANGED
|
@@ -8,9 +8,12 @@
|
|
|
8
8
|
* and in the plane's daily storage sweep. `rollback-*` records and anything else in the folder are never touched.
|
|
9
9
|
* With no current runtime found, nothing goes: which one is safe to remove is then unknown.
|
|
10
10
|
*/
|
|
11
|
-
import { lstatSync, readFileSync, readdirSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
11
|
+
import { lstatSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
12
13
|
import { homedir } from "node:os";
|
|
13
14
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
15
|
+
import { quarantine, quarantineEntries, claimQuarantine, releaseQuarantine, restoreQuarantine, deleteQuarantineSync } from "./sweep-quarantine.js";
|
|
16
|
+
import { processCensus } from "./run-closeout.js";
|
|
14
17
|
const DAY_MS = 86_400_000;
|
|
15
18
|
/** A deploy or update that never finished keeps its stage this long (it may be resumed). */
|
|
16
19
|
export const UNFINISHED_KEEP_MS = 7 * DAY_MS;
|
|
@@ -63,38 +66,46 @@ export function stagedRuntimes(stateDir) {
|
|
|
63
66
|
catch {
|
|
64
67
|
continue;
|
|
65
68
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
|
|
80
|
-
const from = update?.["from"];
|
|
81
|
-
const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
|
|
82
|
-
let keptAside = false;
|
|
69
|
+
stages.push(readStage(path, name));
|
|
70
|
+
}
|
|
71
|
+
return stages.sort((a, b) => b.at - a.at);
|
|
72
|
+
}
|
|
73
|
+
function readStage(path, name, fallbackAt) {
|
|
74
|
+
const deploy = name.startsWith("browser-") ? readJson(join(path, "deployment.json")) : null;
|
|
75
|
+
const update = name.startsWith("release-") ? readJson(join(path, "update.json")) : null;
|
|
76
|
+
const journal = deploy ?? update;
|
|
77
|
+
const stamp = String(journal?.["deployedAt"] ?? journal?.["startedAt"] ?? journal?.["createdAt"] ?? "");
|
|
78
|
+
let at = Date.parse(stamp);
|
|
79
|
+
if (!Number.isFinite(at)) {
|
|
83
80
|
try {
|
|
84
|
-
|
|
81
|
+
at = fallbackAt ?? statSync(path).mtimeMs;
|
|
85
82
|
}
|
|
86
83
|
catch {
|
|
87
|
-
|
|
84
|
+
at = 0;
|
|
88
85
|
}
|
|
89
|
-
stages.push({ path, name, at, finished, prior, keptAside });
|
|
90
86
|
}
|
|
91
|
-
|
|
87
|
+
const finished = deploy !== null ? deploy["phase"] === "deployed" : update !== null ? typeof update["finishedAt"] === "string" : false;
|
|
88
|
+
const from = update?.["from"];
|
|
89
|
+
const prior = typeof deploy?.["priorRuntime"] === "string" ? deploy["priorRuntime"] : typeof from?.dist === "string" ? from.dist : null;
|
|
90
|
+
let keptAside = false;
|
|
91
|
+
try {
|
|
92
|
+
keptAside = readdirSync(path).some(one => /^orders\.(?:kept|unreadable)\./.test(one));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
keptAside = true;
|
|
96
|
+
}
|
|
97
|
+
return { path, name, at, finished, prior, keptAside };
|
|
98
|
+
}
|
|
99
|
+
/** Where the CLI links usually are (link.ts, an npm or Homebrew global), on PATH or not: a launchd worker's PATH is short. */
|
|
100
|
+
function linkFolders(home) {
|
|
101
|
+
return [join(home, ".local", "bin"), join(home, "bin"), "/usr/local/bin", "/opt/homebrew/bin"];
|
|
92
102
|
}
|
|
93
103
|
/**
|
|
94
104
|
* Paths a runtime may be running from: the launchd services' definitions, every `toolroll` (or older
|
|
95
|
-
* `standing-orders`) command on PATH or under nvm
|
|
105
|
+
* `standing-orders`) command on PATH, in the usual link folders or under nvm (a link counts by its target), this
|
|
106
|
+
* process, and `extra`.
|
|
96
107
|
*/
|
|
97
|
-
export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "") {
|
|
108
|
+
export function runtimesInUse(extra = [], home = homedir(), path = process.env["PATH"] ?? "", stateDir) {
|
|
98
109
|
const found = [...extra];
|
|
99
110
|
const agents = join(home, "Library", "LaunchAgents");
|
|
100
111
|
try {
|
|
@@ -104,7 +115,7 @@ export function runtimesInUse(extra = [], home = homedir(), path = process.env["
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
catch { /* no launch agents here */ }
|
|
107
|
-
const commands = path.split(":").filter(Boolean).flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
|
|
118
|
+
const commands = [...new Set([...path.split(":").filter(Boolean), ...linkFolders(home)])].flatMap(dir => ["toolroll", "standing-orders"].map(bin => join(dir, bin)));
|
|
108
119
|
try {
|
|
109
120
|
for (const version of readdirSync(join(home, ".nvm", "versions", "node")))
|
|
110
121
|
for (const bin of ["toolroll", "standing-orders"])
|
|
@@ -116,12 +127,84 @@ export function runtimesInUse(extra = [], home = homedir(), path = process.env["
|
|
|
116
127
|
lstatSync(command);
|
|
117
128
|
found.push(command);
|
|
118
129
|
}
|
|
119
|
-
catch {
|
|
130
|
+
catch {
|
|
131
|
+
continue; /* not there */
|
|
132
|
+
}
|
|
133
|
+
// A link counts by its target, even one that doesn't resolve now.
|
|
134
|
+
try {
|
|
135
|
+
found.push(resolve(dirname(command), readlinkSync(command)));
|
|
136
|
+
}
|
|
137
|
+
catch { /* not a link */ }
|
|
120
138
|
}
|
|
121
139
|
if (process.argv[1] !== undefined)
|
|
122
140
|
found.push(process.argv[1]);
|
|
141
|
+
if (stateDir !== undefined)
|
|
142
|
+
found.push(...runtimeScriptPaths(stateDir));
|
|
123
143
|
return found;
|
|
124
144
|
}
|
|
145
|
+
/** Retain only recognized Toolroll entry scripts in candidate stages, never complete argv. ps is asked only
|
|
146
|
+
* for Node/Toolroll processes; /proc supplies NUL-separated argv on Linux. Fail closed with a fixed message,
|
|
147
|
+
* since an execFileSync exception can otherwise contain private argument text in stdout/stderr. */
|
|
148
|
+
export function runtimeScriptPaths(stateDir, onlyPids, observations = {}) {
|
|
149
|
+
const stages = stagedRuntimes(stateDir);
|
|
150
|
+
const trash = quarantineEntries(real(join(stateDir, STAGED_FOLDER)));
|
|
151
|
+
const bases = new Set(stages.flatMap(one => [one.path, join(stateDir, STAGED_FOLDER, one.name)]));
|
|
152
|
+
for (const one of trash) {
|
|
153
|
+
bases.add(one.path);
|
|
154
|
+
bases.add(one.original);
|
|
155
|
+
bases.add(join(stateDir, STAGED_FOLDER, one.journal.name));
|
|
156
|
+
}
|
|
157
|
+
if (bases.size === 0 || process.platform === "win32")
|
|
158
|
+
return [];
|
|
159
|
+
const scripts = new Set();
|
|
160
|
+
const collect = (args) => {
|
|
161
|
+
for (const base of bases) {
|
|
162
|
+
const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
163
|
+
// All supported installs: runtime/dist, runtime/node_modules/{toolroll,standing-orders}/dist,
|
|
164
|
+
// or dist directly under a release stage. Matching a known base handles spaces without logging argv.
|
|
165
|
+
const pattern = new RegExp(`(?:^|[\\s\\0"'])(${escaped}/(?:runtime/)?(?:node_modules/(?:toolroll|standing-orders)/)?dist/(?:cli|bin|storage-sweep-child)\\.js)(?=$|[\\s\\0"'])`, "g");
|
|
166
|
+
for (const match of args.matchAll(pattern))
|
|
167
|
+
scripts.add(match[1]);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
try {
|
|
171
|
+
const pids = (observations.census ?? processCensus)().filter(row => /^(?:node(?:js)?|toolroll|standing-orders)(?:\.exe)?$/.test(row.name) &&
|
|
172
|
+
(onlyPids === undefined || onlyPids.includes(row.pid))).map(row => row.pid);
|
|
173
|
+
if (observations.argv !== undefined) {
|
|
174
|
+
for (const args of observations.argv(pids))
|
|
175
|
+
collect(args);
|
|
176
|
+
}
|
|
177
|
+
else if (process.platform === "linux") {
|
|
178
|
+
for (const pid of pids) {
|
|
179
|
+
try {
|
|
180
|
+
collect(readFileSync(`/proc/${pid}/cmdline`, "utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error.code !== "ENOENT" && error.code !== "ESRCH")
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
for (let at = 0; at < pids.length; at += 200) {
|
|
190
|
+
try {
|
|
191
|
+
collect(execFileSync("/bin/ps", ["-ww", "-p", pids.slice(at, at + 200).join(","), "-o", "args="], {
|
|
192
|
+
encoding: "utf8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"],
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
// ps exits 1 when every requested process has just gone. Any other failure keeps stages.
|
|
197
|
+
if (error.status !== 1)
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
throw new Error("Toolroll runtime process paths could not be read");
|
|
205
|
+
}
|
|
206
|
+
return [...scripts];
|
|
207
|
+
}
|
|
125
208
|
/** The stage `path` lies in, or null. */
|
|
126
209
|
function stageOf(stages, path) {
|
|
127
210
|
if (path === null || path === undefined || path === "")
|
|
@@ -138,8 +221,21 @@ function stageOf(stages, path) {
|
|
|
138
221
|
/** What stays and what goes now, and why. */
|
|
139
222
|
export function stagePlan(stateDir, inUse, now) {
|
|
140
223
|
const stages = stagedRuntimes(stateDir);
|
|
224
|
+
const trash = quarantineEntries(real(join(stateDir, STAGED_FOLDER)));
|
|
225
|
+
// A renamed stage still has its original identity for current/previous/newest protection, including a CLI
|
|
226
|
+
// symlink switched just before rename. Partial-delete journals retain this identity for the next retry.
|
|
227
|
+
for (const one of trash)
|
|
228
|
+
if (!stages.some(stage => stage.path === one.original)) {
|
|
229
|
+
const facts = one.journal.deleting ? one.journal.data : readStage(one.path, one.journal.name, one.journal.data.at);
|
|
230
|
+
stages.push({ ...facts, path: one.original });
|
|
231
|
+
}
|
|
232
|
+
stages.sort((a, b) => b.at - a.at);
|
|
233
|
+
const paths = inUse.map(path => {
|
|
234
|
+
const moved = trash.find(one => path === one.path || path.startsWith(one.path + sep));
|
|
235
|
+
return moved === undefined ? path : moved.original + path.slice(moved.path.length);
|
|
236
|
+
});
|
|
141
237
|
const keep = new Map();
|
|
142
|
-
const current =
|
|
238
|
+
const current = paths.map(path => stageOf(stages, path)).filter((one) => one !== null);
|
|
143
239
|
if (stages.length > 0 && current.length === 0)
|
|
144
240
|
return { keep: [], go: [], refused: "no staged runtime is in use, so which one runs now is unknown" };
|
|
145
241
|
for (const one of current)
|
|
@@ -162,29 +258,55 @@ export function stagePlan(stateDir, inUse, now) {
|
|
|
162
258
|
return { keep: [...keep].map(([path, why]) => ({ path, why })), go: stages.filter(one => !keep.has(one.path)), refused: null };
|
|
163
259
|
}
|
|
164
260
|
/**
|
|
165
|
-
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now)
|
|
166
|
-
* still be a real folder directly in staged-upgrades.
|
|
261
|
+
* Remove the stages that go. Right before each goes, the plan is made again (what's in use now), nothing live may use
|
|
262
|
+
* it (`spare`, storage-sweep.ts) and the stage must still be a real folder directly in staged-upgrades.
|
|
167
263
|
*/
|
|
168
264
|
export function pruneStagedRuntimes(stateDir, now, options = {}) {
|
|
169
|
-
const inUse = options.inUse ?? (() => runtimesInUse());
|
|
170
|
-
const first = stagePlan(stateDir, inUse(), now);
|
|
171
|
-
if (first.refused !== null)
|
|
172
|
-
return { removed: [], failed: [], refused: first.refused };
|
|
265
|
+
const inUse = options.inUse ?? (() => runtimesInUse([], undefined, undefined, stateDir));
|
|
173
266
|
const root = real(join(stateDir, STAGED_FOLDER));
|
|
174
267
|
const removed = [];
|
|
175
268
|
const failed = [];
|
|
269
|
+
const finish = (trash) => {
|
|
270
|
+
try {
|
|
271
|
+
options.quarantined?.(trash.original, trash.path);
|
|
272
|
+
const again = stagePlan(stateDir, inUse(), now);
|
|
273
|
+
if (again.refused !== null || !again.go.some(one => one.path === trash.original) || options.spare?.(trash.original) === true || options.spare?.(trash.path) === true) {
|
|
274
|
+
if (!restoreQuarantine(trash))
|
|
275
|
+
failed.push(trash.original);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
deleteQuarantineSync(trash, options.remove ?? (path => rmSync(path, { recursive: true, force: true, maxRetries: 3 })));
|
|
279
|
+
removed.push(trash.original);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
failed.push(trash.original);
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
releaseQuarantine(trash);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
for (const entry of quarantineEntries(root)) {
|
|
289
|
+
const claimed = claimQuarantine(entry);
|
|
290
|
+
if (claimed !== null)
|
|
291
|
+
finish(claimed);
|
|
292
|
+
}
|
|
293
|
+
const first = stagePlan(stateDir, inUse(), now);
|
|
294
|
+
if (first.refused !== null)
|
|
295
|
+
return { removed, failed, refused: first.refused };
|
|
176
296
|
for (const one of first.go) {
|
|
177
297
|
const again = stagePlan(stateDir, inUse(), now);
|
|
178
|
-
if (again.refused !== null || !again.go.some(other => other.path === one.path))
|
|
298
|
+
if (again.refused !== null || !again.go.some(other => other.path === one.path) || options.spare?.(one.path) === true)
|
|
179
299
|
continue;
|
|
180
300
|
try {
|
|
181
301
|
if (lstatSync(one.path).isSymbolicLink() || dirname(one.path) !== root)
|
|
182
302
|
continue;
|
|
183
|
-
|
|
184
|
-
|
|
303
|
+
const trash = quarantine(one.path, one);
|
|
304
|
+
if (trash !== null)
|
|
305
|
+
finish(trash);
|
|
185
306
|
}
|
|
186
|
-
catch {
|
|
187
|
-
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (error.code !== "ENOENT")
|
|
309
|
+
failed.push(one.path);
|
|
188
310
|
}
|
|
189
311
|
}
|
|
190
312
|
return { removed, failed, refused: null };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LiveInput } from "./live-input.js";
|
|
2
|
+
import { type SteerMailbox } from "./steer-mailbox.js";
|
|
3
|
+
import type { SteerNote, Store } from "./store.js";
|
|
4
|
+
/** How often the dispatcher looks: a note reaches a running Claude build within about this long. */
|
|
5
|
+
export declare const LIVE_TICK_MS = 500;
|
|
6
|
+
/** How long an unread note waits before it is sent again. */
|
|
7
|
+
export declare const LIVE_RESEND_MS = 90000;
|
|
8
|
+
/** How many times an unread note is sent again before it reads Not picked up. */
|
|
9
|
+
export declare const LIVE_RESENDS = 3;
|
|
10
|
+
export type LiveSteer = {
|
|
11
|
+
box: SteerMailbox;
|
|
12
|
+
session: LiveInput | null;
|
|
13
|
+
};
|
|
14
|
+
/** The stdin copy of a note: the same fenced text and key as its file, and what to do with the file. */
|
|
15
|
+
export declare function liveNoteText(box: SteerMailbox, number: number, note: SteerNote): string;
|
|
16
|
+
/** A re-send: only the note's number and file, and what to do with it. The note itself was sent in full once. */
|
|
17
|
+
export declare function liveReminderText(box: SteerMailbox, number: number): string;
|
|
18
|
+
/** One tick. `agentRunning`: the agent process is up, so a note written now can be read. */
|
|
19
|
+
export declare function tickLiveSteer(store: Store, steer: LiveSteer, now: Date, agentRunning: boolean): void;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live steering's dispatcher: the builder runs one tick every half second
|
|
3
|
+
* while its agent works, independent of the 60-second lease pulse.
|
|
4
|
+
*
|
|
5
|
+
* - Every provider: what the agent moved to handled/ is recorded read, and a
|
|
6
|
+
* note filed since the last tick is written to the steering folder at once
|
|
7
|
+
* (steer-mailbox.ts). Codex reads it there.
|
|
8
|
+
* - A Claude build with live input (live-input.ts) also gets each note on
|
|
9
|
+
* stdin as soon as it is in the folder, each Pause as an interrupt and
|
|
10
|
+
* each Resume as a short continue frame — pauses first, so Pause and steer
|
|
11
|
+
* stops the turn before the note arrives.
|
|
12
|
+
* - A note the agent has not moved to handled/ is followed up every 90
|
|
13
|
+
* seconds, up to 3 times, with a short reminder naming its file — never
|
|
14
|
+
* the note again, so an agent that already acted on it does not redo the
|
|
15
|
+
* work; 90 seconds after the last one it reads Not picked up. A paused
|
|
16
|
+
* session is not reminded: only a new note or Resume continues it. Moving
|
|
17
|
+
* the file is still the only proof of reading, and a note still unread
|
|
18
|
+
* when the attempt ends reaches the next attempt's brief as before.
|
|
19
|
+
*
|
|
20
|
+
* Every step is bookkeeping: a failure waits for the next tick and never
|
|
21
|
+
* touches the build.
|
|
22
|
+
*/
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
import { fence } from "./brief-fence.js";
|
|
25
|
+
import { pulseSteerMailbox, STEER_HANDLED, steerNoteName } from "./steer-mailbox.js";
|
|
26
|
+
/** How often the dispatcher looks: a note reaches a running Claude build within about this long. */
|
|
27
|
+
export const LIVE_TICK_MS = 500;
|
|
28
|
+
/** How long an unread note waits before it is sent again. */
|
|
29
|
+
export const LIVE_RESEND_MS = 90_000;
|
|
30
|
+
/** How many times an unread note is sent again before it reads Not picked up. */
|
|
31
|
+
export const LIVE_RESENDS = 3;
|
|
32
|
+
/** The stdin copy of a note: the same fenced text and key as its file, and what to do with the file. */
|
|
33
|
+
export function liveNoteText(box, number, note) {
|
|
34
|
+
const name = steerNoteName(number);
|
|
35
|
+
return [
|
|
36
|
+
`Operator steering note ${number}, key ${box.key}, also saved as ${box.folder}/${name}:`,
|
|
37
|
+
fence(note.note),
|
|
38
|
+
`Act on it within the agreed scope, under the brief's rules for steering notes, then move ${box.folder}/${name} into ${box.folder}/${STEER_HANDLED}/ — moving the file is how the operator learns you read it.`,
|
|
39
|
+
].join("\n");
|
|
40
|
+
}
|
|
41
|
+
/** A re-send: only the note's number and file, and what to do with it. The note itself was sent in full once. */
|
|
42
|
+
export function liveReminderText(box, number) {
|
|
43
|
+
const name = steerNoteName(number);
|
|
44
|
+
return `Steering note ${number} is still unread: ${box.folder}/${name}. Act on it if you have not already, then move it into ${box.folder}/${STEER_HANDLED}/.`;
|
|
45
|
+
}
|
|
46
|
+
/** One tick. `agentRunning`: the agent process is up, so a note written now can be read. */
|
|
47
|
+
export function tickLiveSteer(store, steer, now, agentRunning) {
|
|
48
|
+
const { box, session } = steer;
|
|
49
|
+
pulseSteerMailbox(store, box, now, agentRunning);
|
|
50
|
+
if (session === null || !session.open || !agentRunning)
|
|
51
|
+
return;
|
|
52
|
+
for (const pause of store.unsentPauses(box.runId)) {
|
|
53
|
+
const sent = session.interrupt(pause.requestId);
|
|
54
|
+
if (sent === "closed") {
|
|
55
|
+
store.endPause(pause.id, now);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
store.markPauseSent(pause.id, now);
|
|
59
|
+
if (sent === "idle")
|
|
60
|
+
store.markRunPaused(box.runId, now);
|
|
61
|
+
}
|
|
62
|
+
for (const resume of store.unsentResumes(box.runId)) {
|
|
63
|
+
const sent = session.resume(randomUUID());
|
|
64
|
+
if (sent === "sent")
|
|
65
|
+
store.markResumeSent(resume.id, box.runId, now);
|
|
66
|
+
else
|
|
67
|
+
store.endResume(resume.id, now);
|
|
68
|
+
}
|
|
69
|
+
for (const [number, noteId] of [...box.waiting].sort((a, b) => a[0] - b[0])) {
|
|
70
|
+
const note = store.steerNote(noteId);
|
|
71
|
+
if (note === null || note.mailedRun !== box.runId || note.deliveredAt !== null || note.supersededAt !== null || note.liveMissedAt !== null)
|
|
72
|
+
continue;
|
|
73
|
+
const first = note.liveSends === 0;
|
|
74
|
+
if (!first) {
|
|
75
|
+
// A paused session waits for a new note; an old one sent again would continue it.
|
|
76
|
+
if (session.paused)
|
|
77
|
+
continue;
|
|
78
|
+
if (note.liveSentAt === null || now.getTime() - Date.parse(note.liveSentAt) < LIVE_RESEND_MS)
|
|
79
|
+
continue;
|
|
80
|
+
if (note.liveSends > LIVE_RESENDS) {
|
|
81
|
+
store.markLiveMissed(noteId, box.runId, now);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The first send is the note itself, under its own identity; a re-send is a fresh frame (the harness may drop a
|
|
86
|
+
// repeated uuid) that only names the file.
|
|
87
|
+
if (!session.send(first ? liveNoteText(box, number, note) : liveReminderText(box, number), first ? (note.liveUuid ?? randomUUID()) : randomUUID()))
|
|
88
|
+
return;
|
|
89
|
+
store.recordLiveSend(noteId, box.runId, now);
|
|
90
|
+
if (first)
|
|
91
|
+
store.markRunResumed(box.runId, now);
|
|
92
|
+
}
|
|
93
|
+
}
|
package/dist/steer-status.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* either waiting for the agent that is running now, or for the next attempt.
|
|
7
7
|
*/
|
|
8
8
|
import type { SteerNote } from "./store.js";
|
|
9
|
-
export type SteerStanding = "read" | "waiting" | "next-attempt" | "ended" | "unverified";
|
|
9
|
+
export type SteerStanding = "read" | "waiting" | "not-picked-up" | "next-attempt" | "ended" | "unverified";
|
|
10
10
|
/** `liveBuild`: the task has a build whose agent is running and reads its steering folder (Store.steeringLiveBuild). */
|
|
11
11
|
export declare function steerStandingOf(note: SteerNote, liveBuild: boolean): SteerStanding;
|
|
12
12
|
/** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
|
|
@@ -16,4 +16,10 @@ export declare function steerStandingParts(note: SteerNote, liveBuild: boolean):
|
|
|
16
16
|
};
|
|
17
17
|
export declare function steerStandingWords(note: SteerNote, liveBuild: boolean, time: (iso: string) => string): string;
|
|
18
18
|
/** Where a note filed now goes, said before or right after filing it. */
|
|
19
|
-
export declare function steerDestinationWords(liveBuild: boolean): string;
|
|
19
|
+
export declare function steerDestinationWords(liveBuild: boolean, liveInput?: boolean): string;
|
|
20
|
+
/** A paused live build's one sentence: until when it can wait before it ends, and how long that is (live-input.ts
|
|
21
|
+
* idleLimit), or only that it is paused when it has no idle limit. `time` renders the moment its surface's way. */
|
|
22
|
+
export declare function pausedWords(pause: {
|
|
23
|
+
until: string | null;
|
|
24
|
+
limitMs: number | null;
|
|
25
|
+
}, time: (iso: string) => string): string;
|
package/dist/steer-status.js
CHANGED
|
@@ -6,13 +6,17 @@ export function steerStandingOf(note, liveBuild) {
|
|
|
6
6
|
return "ended";
|
|
7
7
|
if (note.deliveredAt !== null)
|
|
8
8
|
return "read";
|
|
9
|
-
|
|
9
|
+
if (!liveBuild)
|
|
10
|
+
return "next-attempt";
|
|
11
|
+
// Sent to the running build every time it was owed, and never moved to handled/ (steer-live.ts).
|
|
12
|
+
return note.liveMissedAt !== null ? "not-picked-up" : "waiting";
|
|
10
13
|
}
|
|
11
14
|
/** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
|
|
12
15
|
export function steerStandingParts(note, liveBuild) {
|
|
13
16
|
switch (steerStandingOf(note, liveBuild)) {
|
|
14
17
|
case "read": return { words: "Read by the agent at", readAt: note.ackedAt ?? note.deliveredAt };
|
|
15
18
|
case "waiting": return { words: "Waiting for the agent to read it", readAt: null };
|
|
19
|
+
case "not-picked-up": return { words: "Not picked up", readAt: null };
|
|
16
20
|
case "next-attempt": return { words: "Will reach the next attempt", readAt: null };
|
|
17
21
|
case "ended": return { words: "The task ended before the agent read it", readAt: null };
|
|
18
22
|
case "unverified": return { words: "Saved before notes required a sign-in; never sent", readAt: null };
|
|
@@ -23,8 +27,18 @@ export function steerStandingWords(note, liveBuild, time) {
|
|
|
23
27
|
return readAt === null ? words : `${words} ${time(readAt)}`;
|
|
24
28
|
}
|
|
25
29
|
/** Where a note filed now goes, said before or right after filing it. */
|
|
26
|
-
export function steerDestinationWords(liveBuild) {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
export function steerDestinationWords(liveBuild, liveInput = false) {
|
|
31
|
+
// A Claude build with live input takes it in seconds (steer-live.ts); any other running build reads its folder.
|
|
32
|
+
return liveBuild && liveInput
|
|
33
|
+
? "The running agent gets it now."
|
|
34
|
+
: liveBuild
|
|
35
|
+
? "The running agent reads it when it next checks for notes."
|
|
36
|
+
: "The next attempt reads it before starting.";
|
|
37
|
+
}
|
|
38
|
+
/** A paused live build's one sentence: until when it can wait before it ends, and how long that is (live-input.ts
|
|
39
|
+
* idleLimit), or only that it is paused when it has no idle limit. `time` renders the moment its surface's way. */
|
|
40
|
+
export function pausedWords(pause, time) {
|
|
41
|
+
if (pause.until === null || pause.limitMs === null)
|
|
42
|
+
return "Paused.";
|
|
43
|
+
return `Paused. It can wait until ${time(pause.until)} (${Math.max(1, Math.round(pause.limitMs / 60_000))} min) before it ends.`;
|
|
30
44
|
}
|