toolroll 0.9.59 → 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 +26 -0
- package/README.md +8 -6
- 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 +2 -1
- package/dist/assignment-adapters.js +1 -1
- package/dist/assignment-delivery.d.ts +16 -0
- package/dist/assignment-delivery.js +54 -28
- package/dist/assignment-status.d.ts +14 -5
- package/dist/assignment-status.js +70 -39
- package/dist/assignment.d.ts +10 -2
- package/dist/assignment.js +21 -14
- package/dist/bin.js +11 -2
- package/dist/brief-fence.d.ts +9 -0
- package/dist/brief-fence.js +22 -0
- package/dist/browser/workspace.css +2 -2
- 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 +178 -29
- package/dist/chat-channel.js +10 -1
- 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/cli-fast.d.ts +8 -0
- package/dist/cli-fast.js +18 -0
- package/dist/cli-help.d.ts +5 -0
- package/dist/cli-help.js +91 -0
- package/dist/cli.d.ts +6 -5
- package/dist/cli.js +17 -96
- 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/design/tokens.css +234 -0
- package/dist/desktop-update.d.ts +1 -1
- package/dist/discord.js +2 -1
- package/dist/evidence-pack.js +1 -1
- package/dist/evidence.d.ts +28 -0
- package/dist/evidence.js +87 -17
- package/dist/exec.d.ts +32 -0
- package/dist/exec.js +229 -10
- package/dist/flow-actions.js +2 -1
- package/dist/guides.js +14 -11
- 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 +49 -2
- package/dist/lead-follow.d.ts +2 -0
- package/dist/lead-follow.js +138 -88
- package/dist/lead-tools.js +48 -8
- 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/mailbox.js +3 -2
- package/dist/maintenance.d.ts +2 -0
- package/dist/maintenance.js +5 -2
- package/dist/mcp-core.js +1 -1
- 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 +201 -19
- 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 +73 -8
- package/dist/provider.js +87 -17
- 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 +22 -3
- package/dist/server/chrome.d.ts +6 -3
- package/dist/server/chrome.js +8 -4
- package/dist/server/console.css +9 -64
- 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 +14 -2
- package/dist/server/render-tasks.d.ts +14 -2
- package/dist/server/render-tasks.js +38 -20
- package/dist/server/route-table.js +4 -1
- package/dist/server/settings.js +2 -1
- package/dist/server/task-views.js +10 -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/slack-socket.d.ts +12 -0
- package/dist/slack-socket.js +29 -0
- package/dist/slack.d.ts +0 -7
- package/dist/slack.js +2 -24
- 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-mailbox.d.ts +39 -0
- package/dist/steer-mailbox.js +207 -0
- package/dist/steer-status.d.ts +25 -0
- package/dist/steer-status.js +44 -0
- 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 +202 -2
- package/dist/store.js +440 -13
- package/dist/surface.js +5 -3
- package/dist/sweep-gate.d.ts +10 -0
- package/dist/sweep-gate.js +18 -0
- 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/team-updates.js +3 -3
- package/dist/test-temp.d.ts +6 -3
- package/dist/test-temp.js +15 -9
- package/dist/toolroll-update.js +15 -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 +21 -21
- package/scripts/provider-canary.mjs +235 -147
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
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Store } from "./store.js";
|
|
2
|
+
export declare const STEER_HANDLED = "handled";
|
|
3
|
+
export type SteerMailbox = {
|
|
4
|
+
runId: number;
|
|
5
|
+
taskRef: number;
|
|
6
|
+
/** Worktree-relative, as the brief names it. */
|
|
7
|
+
folder: string;
|
|
8
|
+
dir: string;
|
|
9
|
+
/** The folder's real path, resolved once when it was made inside the worktree. */
|
|
10
|
+
realDir: string;
|
|
11
|
+
/** This attempt's note key: in the brief and in every genuine note, nowhere else. */
|
|
12
|
+
key: string;
|
|
13
|
+
/** The highest number this pulse has written. */
|
|
14
|
+
last: number;
|
|
15
|
+
/** Mailed and not yet moved to handled/: number → note id. */
|
|
16
|
+
waiting: Map<number, number>;
|
|
17
|
+
};
|
|
18
|
+
export declare const steerNoteName: (number: number) => string;
|
|
19
|
+
/** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
|
|
20
|
+
export declare function openSteerMailbox(worktree: string, folder: string, runId: number, taskRef: number, key: string): SteerMailbox | null;
|
|
21
|
+
/** One beat: record what the agent read, then mail what is new while the agent runs. */
|
|
22
|
+
export declare function pulseSteerMailbox(store: Store, box: SteerMailbox, now: Date, agentRunning: boolean): void;
|
|
23
|
+
/** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
|
|
24
|
+
export declare function closeSteerMailbox(store: Store, box: SteerMailbox, now: Date): void;
|
|
25
|
+
/**
|
|
26
|
+
* Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
|
|
27
|
+
* while a note is unread, hands the agent one line of context between tool
|
|
28
|
+
* calls. It only says notes are waiting — it never quotes one, and its firing
|
|
29
|
+
* is never an acknowledgement. Rendered into the run's own --settings.
|
|
30
|
+
*/
|
|
31
|
+
export declare function claudeSteerHooks(dir: string, folder: string): {
|
|
32
|
+
PostToolUse: {
|
|
33
|
+
matcher: string;
|
|
34
|
+
hooks: {
|
|
35
|
+
type: "command";
|
|
36
|
+
command: string;
|
|
37
|
+
}[];
|
|
38
|
+
}[];
|
|
39
|
+
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The running build's steering mailbox (v120). A note an operator files
|
|
3
|
+
* while a build runs used to wait for the next attempt; now the builder's
|
|
4
|
+
* pulse writes it into one nonce-named folder in the worktree, as a numbered
|
|
5
|
+
* file the agent reads and acknowledges by moving it into `handled/` — the
|
|
6
|
+
* same small contract a person's own inbox keeps:
|
|
7
|
+
*
|
|
8
|
+
* - The durable record is the delivery. A note is mailed (written into the
|
|
9
|
+
* folder) and read (moved by the agent); nothing else counts. A hook
|
|
10
|
+
* reminding the agent, or a file the pulse wrote, proves nothing.
|
|
11
|
+
* - Numbers are never reused: the next one is past every name in the folder
|
|
12
|
+
* and in `handled/`, and past every one this pulse has used.
|
|
13
|
+
* - Whatever is still unread when the attempt ends keeps today's road: the
|
|
14
|
+
* note stays pending, the next attempt's brief quotes it, and a task that
|
|
15
|
+
* ends first supersedes it.
|
|
16
|
+
*
|
|
17
|
+
* Every step is bookkeeping, and bookkeeping never fails a build: a folder
|
|
18
|
+
* the agent removed is made again, one it replaced with anything but a real
|
|
19
|
+
* directory is never written through, and an error waits for the next beat.
|
|
20
|
+
*
|
|
21
|
+
* Two boundaries, because the folder lives where the agent and anything it
|
|
22
|
+
* runs can write:
|
|
23
|
+
* - Every genuine note carries this attempt's key, which only the brief and
|
|
24
|
+
* the notes themselves name. A file in the folder without it was not
|
|
25
|
+
* written by the operator, and the brief tells the agent to act on none.
|
|
26
|
+
* (The brief rides the agent's argv, so a process that reads argv could
|
|
27
|
+
* learn it: this keeps ordinary repository code from posing as the
|
|
28
|
+
* operator, not a determined local process.)
|
|
29
|
+
* - The pulse writes only while the folder still resolves to this worktree's
|
|
30
|
+
* own folder, before and after each write, so swapping it for a link can
|
|
31
|
+
* never make the plane write a note somewhere else.
|
|
32
|
+
*/
|
|
33
|
+
import { lstatSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
import { fence } from "./brief-fence.js";
|
|
36
|
+
export const STEER_HANDLED = "handled";
|
|
37
|
+
const NOTE_NAME = /^(\d+)\.json$/;
|
|
38
|
+
export const steerNoteName = (number) => `${String(number).padStart(3, "0")}.json`;
|
|
39
|
+
const isRealDirectory = (path) => {
|
|
40
|
+
try {
|
|
41
|
+
return lstatSync(path).isDirectory();
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** Make the folder and its handled/ inside the worktree; null when it cannot be made. */
|
|
48
|
+
export function openSteerMailbox(worktree, folder, runId, taskRef, key) {
|
|
49
|
+
let realDir;
|
|
50
|
+
try {
|
|
51
|
+
realDir = join(realpathSync(worktree), folder);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const box = { runId, taskRef, folder, dir: join(worktree, folder), realDir, key, last: 0, waiting: new Map() };
|
|
57
|
+
return ensureFolder(box) ? box : null;
|
|
58
|
+
}
|
|
59
|
+
/** The folder is still this worktree's own, not a link to somewhere else. */
|
|
60
|
+
const stillInside = (box) => {
|
|
61
|
+
try {
|
|
62
|
+
return realpathSync(box.dir) === box.realDir;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
function ensureFolder(box) {
|
|
69
|
+
for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
|
|
70
|
+
try {
|
|
71
|
+
mkdirSync(path, { mode: 0o700 });
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== "EEXIST")
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// Something the agent put in the folder's place is never written through.
|
|
78
|
+
if (!isRealDirectory(path))
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return stillInside(box);
|
|
82
|
+
}
|
|
83
|
+
function highestNumber(box) {
|
|
84
|
+
let highest = box.last;
|
|
85
|
+
for (const path of [box.dir, join(box.dir, STEER_HANDLED)]) {
|
|
86
|
+
let names;
|
|
87
|
+
try {
|
|
88
|
+
names = readdirSync(path);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const name of names) {
|
|
94
|
+
const number = NOTE_NAME.exec(name)?.[1];
|
|
95
|
+
if (number !== undefined)
|
|
96
|
+
highest = Math.max(highest, Number(number));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return highest;
|
|
100
|
+
}
|
|
101
|
+
/** Every waiting note the agent moved into handled/ is read, by this run. */
|
|
102
|
+
function collectAcknowledgements(store, box, now) {
|
|
103
|
+
if (box.waiting.size === 0)
|
|
104
|
+
return;
|
|
105
|
+
const handled = join(box.dir, STEER_HANDLED);
|
|
106
|
+
if (!isRealDirectory(box.dir) || !isRealDirectory(handled))
|
|
107
|
+
return;
|
|
108
|
+
for (const [number, noteId] of [...box.waiting]) {
|
|
109
|
+
let moved;
|
|
110
|
+
try {
|
|
111
|
+
moved = lstatSync(join(handled, steerNoteName(number))).isFile();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
moved = false;
|
|
115
|
+
}
|
|
116
|
+
if (!moved)
|
|
117
|
+
continue;
|
|
118
|
+
box.waiting.delete(number);
|
|
119
|
+
store.ackSteerNote(noteId, box.runId, now);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/** Remove a name this pulse wrote, only while the folder is still this worktree's own; a link goes, never its target. */
|
|
123
|
+
function removeOwn(box, name) {
|
|
124
|
+
if (!stillInside(box))
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(join(box.dir, name));
|
|
128
|
+
}
|
|
129
|
+
catch { /* never written, or the agent already took it */ }
|
|
130
|
+
}
|
|
131
|
+
/** Write each note this run has not seen yet: a temp name, then the number. */
|
|
132
|
+
function mailNotes(store, box, now) {
|
|
133
|
+
const notes = store.mailableSteerNotes(box.taskRef, box.runId);
|
|
134
|
+
if (notes.length === 0 || !ensureFolder(box))
|
|
135
|
+
return;
|
|
136
|
+
for (const note of notes) {
|
|
137
|
+
if (!stillInside(box))
|
|
138
|
+
return;
|
|
139
|
+
const number = highestNumber(box) + 1;
|
|
140
|
+
const name = steerNoteName(number);
|
|
141
|
+
const temp = `.${name}.tmp`;
|
|
142
|
+
const body = { version: 1, number, key: box.key, filedAt: note.createdAt, note: fence(note.note) };
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(join(box.dir, temp), `${JSON.stringify(body, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
145
|
+
renameSync(join(box.dir, temp), join(box.dir, name));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
removeOwn(box, temp);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Checked again after the write: a folder swapped for a link mid-write
|
|
152
|
+
// leaves the note unrecorded, deletes nothing, and mails nothing more this beat.
|
|
153
|
+
if (!stillInside(box))
|
|
154
|
+
return;
|
|
155
|
+
box.last = number;
|
|
156
|
+
let recorded;
|
|
157
|
+
try {
|
|
158
|
+
recorded = store.markSteerMailed(note.id, box.runId, now);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
recorded = false;
|
|
162
|
+
}
|
|
163
|
+
if (recorded) {
|
|
164
|
+
box.waiting.set(number, note.id);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
// The task ended or the note settled between the read and the write, or
|
|
168
|
+
// the mailing could not be recorded: an unrecorded file would only be
|
|
169
|
+
// mailed twice, so it goes, and the next beat tries again.
|
|
170
|
+
removeOwn(box, name);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/** One beat: record what the agent read, then mail what is new while the agent runs. */
|
|
175
|
+
export function pulseSteerMailbox(store, box, now, agentRunning) {
|
|
176
|
+
collectAcknowledgements(store, box, now);
|
|
177
|
+
if (agentRunning)
|
|
178
|
+
mailNotes(store, box, now);
|
|
179
|
+
}
|
|
180
|
+
/** The attempt is over: one last read, then the folder goes. Unread notes stay pending for the next attempt. */
|
|
181
|
+
export function closeSteerMailbox(store, box, now) {
|
|
182
|
+
try {
|
|
183
|
+
collectAcknowledgements(store, box, now);
|
|
184
|
+
}
|
|
185
|
+
catch { /* the notes stay pending */ }
|
|
186
|
+
try {
|
|
187
|
+
rmSync(box.dir, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
catch { /* the next attempt's sweep removes it */ }
|
|
190
|
+
}
|
|
191
|
+
const shellQuote = (value) => `'${value.replace(/'/g, `'\\''`)}'`;
|
|
192
|
+
/**
|
|
193
|
+
* Claude's doorbell: a synchronous PostToolUse hook that lists the folder and,
|
|
194
|
+
* while a note is unread, hands the agent one line of context between tool
|
|
195
|
+
* calls. It only says notes are waiting — it never quotes one, and its firing
|
|
196
|
+
* is never an acknowledgement. Rendered into the run's own --settings.
|
|
197
|
+
*/
|
|
198
|
+
export function claudeSteerHooks(dir, folder) {
|
|
199
|
+
const context = {
|
|
200
|
+
hookSpecificOutput: {
|
|
201
|
+
hookEventName: "PostToolUse",
|
|
202
|
+
additionalContext: `Operator steering waiting: %s unread note(s) in ${folder}/. Read them in number order now, act on each within the agreed scope, then move each into ${folder}/${STEER_HANDLED}/.`,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
const command = `set -- ${shellQuote(dir)}/[0-9]*.json; [ -e "$1" ] || exit 0; printf ${shellQuote(`${JSON.stringify(context)}\n`)} "$#"`;
|
|
206
|
+
return { PostToolUse: [{ matcher: "*", hooks: [{ type: "command", command }] }] };
|
|
207
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a steering note stands, in the one set of words the task page, the
|
|
3
|
+
* CLI and the lead all use (v120). A note is read only when the agent proves
|
|
4
|
+
* it: the stream's receipt of a brief that quoted it, or the agent moving it
|
|
5
|
+
* to handled/ in a running build's steering folder. Everything else is
|
|
6
|
+
* either waiting for the agent that is running now, or for the next attempt.
|
|
7
|
+
*/
|
|
8
|
+
import type { SteerNote } from "./store.js";
|
|
9
|
+
export type SteerStanding = "read" | "waiting" | "not-picked-up" | "next-attempt" | "ended" | "unverified";
|
|
10
|
+
/** `liveBuild`: the task has a build whose agent is running and reads its steering folder (Store.steeringLiveBuild). */
|
|
11
|
+
export declare function steerStandingOf(note: SteerNote, liveBuild: boolean): SteerStanding;
|
|
12
|
+
/** The words, and the moment the agent read it when it has: each surface renders that time its own way. */
|
|
13
|
+
export declare function steerStandingParts(note: SteerNote, liveBuild: boolean): {
|
|
14
|
+
words: string;
|
|
15
|
+
readAt: string | null;
|
|
16
|
+
};
|
|
17
|
+
export declare function steerStandingWords(note: SteerNote, liveBuild: boolean, time: (iso: string) => string): string;
|
|
18
|
+
/** Where a note filed now goes, said before or right after filing it. */
|
|
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;
|