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.
Files changed (152) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +8 -6
  3. package/dist/agent-board.d.ts +208 -0
  4. package/dist/agent-board.js +454 -0
  5. package/dist/agent-fence.d.ts +19 -0
  6. package/dist/agent-fence.js +100 -8
  7. package/dist/agent-pulse.d.ts +43 -0
  8. package/dist/agent-pulse.js +214 -0
  9. package/dist/agent-turns.d.ts +52 -0
  10. package/dist/agent-turns.js +137 -0
  11. package/dist/agents-board.d.ts +101 -0
  12. package/dist/agents-board.js +225 -0
  13. package/dist/assignment-adapters.d.ts +2 -1
  14. package/dist/assignment-adapters.js +1 -1
  15. package/dist/assignment-delivery.d.ts +16 -0
  16. package/dist/assignment-delivery.js +54 -28
  17. package/dist/assignment-status.d.ts +14 -5
  18. package/dist/assignment-status.js +70 -39
  19. package/dist/assignment.d.ts +10 -2
  20. package/dist/assignment.js +21 -14
  21. package/dist/bin.js +11 -2
  22. package/dist/brief-fence.d.ts +9 -0
  23. package/dist/brief-fence.js +22 -0
  24. package/dist/browser/workspace.css +2 -2
  25. package/dist/browser/workspace.js +29 -25
  26. package/dist/browser-crew.js +2 -0
  27. package/dist/browser-workspace.d.ts +79 -1
  28. package/dist/browser-workspace.js +1 -0
  29. package/dist/builder.d.ts +8 -0
  30. package/dist/builder.js +178 -29
  31. package/dist/chat-channel.js +10 -1
  32. package/dist/chat-core.d.ts +6 -0
  33. package/dist/chat-core.js +8 -0
  34. package/dist/chat-delivery.js +22 -0
  35. package/dist/chat-messages.js +8 -1
  36. package/dist/chat-quiet.js +1 -0
  37. package/dist/chat-voice.d.ts +2 -0
  38. package/dist/chat-voice.js +2 -0
  39. package/dist/checkout-cleanup.d.ts +3 -0
  40. package/dist/checkout-cleanup.js +11 -2
  41. package/dist/cli-fast.d.ts +8 -0
  42. package/dist/cli-fast.js +18 -0
  43. package/dist/cli-help.d.ts +5 -0
  44. package/dist/cli-help.js +91 -0
  45. package/dist/cli.d.ts +6 -5
  46. package/dist/cli.js +17 -96
  47. package/dist/contracts/console-api.d.ts +3 -1
  48. package/dist/contracts/console-api.js +5 -1
  49. package/dist/contracts/lead-tools.d.ts +16 -2
  50. package/dist/contracts/lead-tools.js +5 -1
  51. package/dist/design/tokens.css +234 -0
  52. package/dist/desktop-update.d.ts +1 -1
  53. package/dist/discord.js +2 -1
  54. package/dist/evidence-pack.js +1 -1
  55. package/dist/evidence.d.ts +28 -0
  56. package/dist/evidence.js +87 -17
  57. package/dist/exec.d.ts +32 -0
  58. package/dist/exec.js +229 -10
  59. package/dist/flow-actions.js +2 -1
  60. package/dist/guides.js +14 -11
  61. package/dist/invoke.js +110 -18
  62. package/dist/landed-cleanup.d.ts +157 -0
  63. package/dist/landed-cleanup.js +707 -0
  64. package/dist/lead-cli.js +22 -19
  65. package/dist/lead-contract.js +1 -1
  66. package/dist/lead-doors.js +49 -2
  67. package/dist/lead-follow.d.ts +2 -0
  68. package/dist/lead-follow.js +138 -88
  69. package/dist/lead-tools.js +48 -8
  70. package/dist/live-input.d.ts +68 -0
  71. package/dist/live-input.js +254 -0
  72. package/dist/live.d.ts +1 -1
  73. package/dist/live.js +4 -2
  74. package/dist/mailbox.js +3 -2
  75. package/dist/maintenance.d.ts +2 -0
  76. package/dist/maintenance.js +5 -2
  77. package/dist/mcp-core.js +1 -1
  78. package/dist/operate-remote-arguments.js +5 -1
  79. package/dist/operate-remote.js +1 -1
  80. package/dist/operate.d.ts +1 -1
  81. package/dist/operate.js +201 -19
  82. package/dist/orphan-sweep.d.ts +2 -0
  83. package/dist/orphan-sweep.js +5 -3
  84. package/dist/project-delete-ui.d.ts +11 -0
  85. package/dist/project-delete-ui.js +29 -2
  86. package/dist/project-tools.d.ts +7 -11
  87. package/dist/project-tools.js +13 -37
  88. package/dist/provider.d.ts +73 -8
  89. package/dist/provider.js +87 -17
  90. package/dist/result-completion.d.ts +7 -0
  91. package/dist/result-completion.js +9 -0
  92. package/dist/runner.d.ts +4 -2
  93. package/dist/runner.js +3 -3
  94. package/dist/serve.js +22 -3
  95. package/dist/server/chrome.d.ts +6 -3
  96. package/dist/server/chrome.js +8 -4
  97. package/dist/server/console.css +9 -64
  98. package/dist/server/live.d.ts +3 -0
  99. package/dist/server/live.js +35 -2
  100. package/dist/server/pages.js +59 -35
  101. package/dist/server/render-chat.js +14 -2
  102. package/dist/server/render-tasks.d.ts +14 -2
  103. package/dist/server/render-tasks.js +38 -20
  104. package/dist/server/route-table.js +4 -1
  105. package/dist/server/settings.js +2 -1
  106. package/dist/server/task-views.js +10 -0
  107. package/dist/server/tasks.js +63 -0
  108. package/dist/shared-deps.d.ts +9 -4
  109. package/dist/shared-deps.js +89 -13
  110. package/dist/slack-socket.d.ts +12 -0
  111. package/dist/slack-socket.js +29 -0
  112. package/dist/slack.d.ts +0 -7
  113. package/dist/slack.js +2 -24
  114. package/dist/staged-runtimes.d.ts +16 -4
  115. package/dist/staged-runtimes.js +160 -38
  116. package/dist/steer-live.d.ts +19 -0
  117. package/dist/steer-live.js +93 -0
  118. package/dist/steer-mailbox.d.ts +39 -0
  119. package/dist/steer-mailbox.js +207 -0
  120. package/dist/steer-status.d.ts +25 -0
  121. package/dist/steer-status.js +44 -0
  122. package/dist/storage-sweep-child.d.ts +1 -0
  123. package/dist/storage-sweep-child.js +76 -0
  124. package/dist/storage-sweep-process.d.ts +41 -0
  125. package/dist/storage-sweep-process.js +187 -0
  126. package/dist/storage-sweep.d.ts +30 -6
  127. package/dist/storage-sweep.js +214 -28
  128. package/dist/storage-ui.d.ts +2 -0
  129. package/dist/storage-ui.js +16 -4
  130. package/dist/storage.d.ts +6 -0
  131. package/dist/storage.js +35 -0
  132. package/dist/store.d.ts +202 -2
  133. package/dist/store.js +440 -13
  134. package/dist/surface.js +5 -3
  135. package/dist/sweep-gate.d.ts +10 -0
  136. package/dist/sweep-gate.js +18 -0
  137. package/dist/sweep-quarantine.d.ts +28 -0
  138. package/dist/sweep-quarantine.js +153 -0
  139. package/dist/task-status.d.ts +2 -0
  140. package/dist/task-status.js +5 -1
  141. package/dist/team-updates.js +3 -3
  142. package/dist/test-temp.d.ts +6 -3
  143. package/dist/test-temp.js +15 -9
  144. package/dist/toolroll-update.js +15 -2
  145. package/dist/work-index.d.ts +1 -0
  146. package/dist/work-index.js +32 -12
  147. package/dist/work-summary.d.ts +2 -2
  148. package/dist/workspace-revision.js +3 -1
  149. package/dist/worktree.d.ts +3 -1
  150. package/dist/worktree.js +93 -10
  151. package/package.json +21 -21
  152. package/scripts/provider-canary.mjs +235 -147
@@ -46,6 +46,7 @@ import { runCostWords } from "../summary.js";
46
46
  import { requestTaskStop, resumeTaskStop } from "../task-control.js";
47
47
  import { templateByName } from "../templates.js";
48
48
  import { TEXT_LIMITS } from "../text-limits.js";
49
+ import { agentQuestion, answerAgentQuestion, markAgentSeen } from "../agent-board.js";
49
50
  import { WorkIndexCursorError, workIndexPage } from "../work-index.js";
50
51
  import { buildProgressOf, parseWorkView } from "../workspace-ui.js";
51
52
  import { requestContext } from "./request-context.js";
@@ -458,6 +459,9 @@ export function createTasksHandlers(runtime) {
458
459
  return sendScreen(response, 409, screen("Result changed", html `<h1>Result changed</h1><p>This acceptance link no longer matches the current result. Review the current task before accepting.</p><p class="refusal-back"><a class="button-link" href="/review">Review results</a></p>`, { chrome: chromeFor(project, "runs") }));
459
460
  }
460
461
  const selected = selectedRow === null ? null : reviewCockpitViewOf(selectedRow, who, now);
462
+ // Opening a run's own result page is viewing it: it leaves this person's unread Done on the Agents board.
463
+ if (wantedId !== null && selected?.run != null)
464
+ markAgentSeen(store, who.name, selected.run.id, now);
461
465
  // A build that delivered nothing reads Failed here, and so does a failed task's delivered result: what went wrong
462
466
  // is the card and Retry the ink act. That result may still be accepted, but only in outline, as Accept anyway, with
463
467
  // a reason.
@@ -1621,8 +1625,31 @@ export function createTasksHandlers(runtime) {
1621
1625
  : "no such task";
1622
1626
  return taskScreen(response, who, taskId, said, 400);
1623
1627
  }
1628
+ // From the Agents board, back to the same agent (allow-listed: a run of this task, never a URL from the form).
1629
+ const run = Number(body.get("run") ?? "");
1630
+ if (body.get("return") === "agents" && Number.isSafeInteger(run) && store.getRun(run)?.taskRef === ref.id)
1631
+ return redirect(response, `/agents?run=${run}&said=steered`);
1624
1632
  return redirect(response, taskHref(taskId));
1625
1633
  }
1634
+ case "reply": {
1635
+ const body = readForm(posted, CONSOLE_FORMS.taskReply);
1636
+ // A reply is steering, so it is a browser session's act too.
1637
+ if (who.via !== "cookie")
1638
+ return refuse(response, who, 403, "replying to an agent is a browser session's act");
1639
+ // The same gates as a pause: the route's act scope already refuses a viewer; these say why, and the demo answers nothing.
1640
+ if (who.role !== "approver")
1641
+ return taskScreen(response, who, taskId, "your login can watch — replying to an agent is an approver's act", 403);
1642
+ if (store.isDemo())
1643
+ return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
1644
+ const id = Number(body.get("question") ?? "");
1645
+ const asked = Number.isSafeInteger(id) && id > 0 ? agentQuestion(store, id) : null;
1646
+ if (asked === null || asked.taskRef !== ref.id)
1647
+ return taskScreen(response, who, taskId, "that question is not on this task", 404);
1648
+ const answered = answerAgentQuestion(store, { question: id, answer: body.get("answer") ?? "", author: who.name, at: now,
1649
+ file: (task, note) => store.fileSteerNote(task, verifiedAuthor(who.name), note, now) });
1650
+ const said = answered.ok ? "replied" : answered.reason === "invalid-answer" || answered.reason === "not-saved" ? "reply-invalid" : answered.reason === "closed" ? "reply-closed" : "reply-stale";
1651
+ return redirect(response, `/agents?run=${asked.run}&said=${said}`);
1652
+ }
1626
1653
  case "hold": {
1627
1654
  const body = readForm(posted, CONSOLE_FORMS.taskHold);
1628
1655
  const reason = (body.get("reason") ?? "").trim() || "held from the console";
@@ -2306,6 +2333,40 @@ export function createTasksHandlers(runtime) {
2306
2333
  const back = body.get("return") ?? "";
2307
2334
  return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2308
2335
  }
2336
+ case "pause": {
2337
+ const body = readForm(posted, CONSOLE_FORMS.taskPause);
2338
+ // Pause, and Pause and steer (live steering): a browser session's act, an approver's act, naming the ONE run
2339
+ // the page showed under the page's own request id. It stops the running Claude build's turn and keeps the
2340
+ // build; a note that rides along is filed in the same transaction and sent right after. mode=resume continues a
2341
+ // paused build with no note. Never a stop.
2342
+ if (who.via !== "cookie")
2343
+ return refuse(response, who, 403, "pausing a build is a browser session's act");
2344
+ if (who.role !== "approver")
2345
+ return taskScreen(response, who, taskId, "your login can watch — pausing a build is an approver's act", 403);
2346
+ if (store.isDemo())
2347
+ return taskScreen(response, who, taskId, "the demo authorizes nothing", 403);
2348
+ const named = (body.get("run") ?? "").trim();
2349
+ if (!/^[0-9]{1,15}$/.test(named))
2350
+ return taskScreen(response, who, taskId, "which build? a pause names one exact run", 400);
2351
+ const note = (body.get("note") ?? "").trim();
2352
+ // Resume: the paused build goes on where it stopped; no note is filed.
2353
+ const asked = body.get("mode") === "resume"
2354
+ ? store.requestResume({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "" }, now)
2355
+ : store.requestPause({ taskId, runId: Number(named), by: verifiedAuthor(who.name), via: "web", requestId: body.get("request") ?? "", ...(note === "" ? {} : { note }) }, now);
2356
+ if (!asked.ok) {
2357
+ const words = {
2358
+ stale: `build #${named} has stopped taking notes — reload to see the current attempt`,
2359
+ "not-paused": `build #${named} isn't paused any more — reload to see where it stands`,
2360
+ "invalid-request": "that form was incomplete — reload and try again",
2361
+ duplicate: "that form was already used for another build — reload and try again",
2362
+ "task-finished": "this task is finished",
2363
+ "unknown-task": "no such task",
2364
+ };
2365
+ return taskScreen(response, who, taskId, words[asked.reason] ?? asked.detail, asked.reason === "invalid-note" ? 400 : 409);
2366
+ }
2367
+ const back = body.get("return") ?? "";
2368
+ return redirect(response, back === "chat" ? `${taskChatHref(taskId)}#task-control` : `${taskHref(taskId)}#task-control`);
2369
+ }
2309
2370
  case "resume-arm": {
2310
2371
  const body = readForm(posted, CONSOLE_FORMS.taskResume);
2311
2372
  return armTaskResume(response, who, taskId, (body.get("run") ?? "").trim(), body.get("return") === "chat" ? "chat" : "task", now);
@@ -2715,6 +2776,7 @@ export function createTasksHandlers(runtime) {
2715
2776
  "task.act.next": taskAct("next"),
2716
2777
  "task.act.reopen": taskAct("reopen"),
2717
2778
  "task.act.steer": taskAct("steer"),
2779
+ "task.act.reply": taskAct("reply"),
2718
2780
  "task.act.accept-proof": taskAct("accept-proof"),
2719
2781
  "task.act.accept-revision": taskAct("accept-revision"),
2720
2782
  "task.act.reject-revision": taskAct("reject-revision"),
@@ -2724,6 +2786,7 @@ export function createTasksHandlers(runtime) {
2724
2786
  "task.act.merge": taskMerge,
2725
2787
  "task.act.confirm-stopped": taskConfirmStopped,
2726
2788
  "task.act.stop": taskAct("stop"),
2789
+ "task.act.pause": taskAct("pause"),
2727
2790
  "task.act.resume-arm": taskAct("resume-arm"),
2728
2791
  "task.act.resume": taskAct("resume"),
2729
2792
  "task.act.block": taskAct("block"),
@@ -98,13 +98,18 @@ export declare function unusedShared(root: string, checkouts: readonly {
98
98
  export declare function removeShared(path: string): boolean;
99
99
  /**
100
100
  * The daily sweep: remove each copy no checkout uses and none has linked for `unusedMs` (a week), and old debris.
101
- * Right before a copy goes, who uses it and when it was last linked are read again; a copy a checkout picked up in
102
- * between stays.
101
+ * Right before a copy goes, who uses it, when it was last linked and whether live work uses it (`spare`,
102
+ * storage-sweep.ts) are read again; a copy a checkout picked up in between stays. Removal never holds the event loop.
103
103
  */
104
104
  export declare function pruneUnusedShared(root: string, checkouts: () => readonly {
105
105
  path: string;
106
106
  repo: string;
107
- }[], now: Date, unusedMs?: number): {
107
+ }[], now: Date, unusedMs?: number, options?: {
108
+ spare?: (path: string) => boolean;
109
+ /** Deterministic race/failure seams; never supplied by the worker. */
110
+ quarantined?: (original: string, trash: string) => void | Promise<void>;
111
+ remove?: (path: string) => Promise<void>;
112
+ }): Promise<{
108
113
  removed: SharedUse[];
109
114
  failed: string[];
110
- };
115
+ }>;
@@ -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
- writeFileSync(join(at, LINK_NOTE), `${shared}\n`, "utf8");
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
- const copies = sharedCopies(root);
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 && matchesInputs(copy.dir, ready, inputs))
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 and when it was last linked are read again; a copy a checkout picked up in
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
- if (removeShared(fresh.dir))
471
- removed.push(fresh);
472
- else
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
- if (!removeShared(debris))
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
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The Socket Mode client, apart from the follower so the Slack SDK loads only
3
+ * once Slack is set up (slack.ts imports this when it finds credentials).
4
+ */
5
+ import { SocketModeClient } from "@slack/socket-mode";
6
+ export { LogLevel } from "@slack/socket-mode";
7
+ /** The bot and socket tokens must belong to the same app, including after reconnect. */
8
+ export declare class ToolrollSlackSocket extends SocketModeClient {
9
+ expectedApp: string;
10
+ wrongApp: () => void;
11
+ protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
12
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The Socket Mode client, apart from the follower so the Slack SDK loads only
3
+ * once Slack is set up (slack.ts imports this when it finds credentials).
4
+ */
5
+ import { SocketModeClient } from "@slack/socket-mode";
6
+ export { LogLevel } from "@slack/socket-mode";
7
+ /** The bot and socket tokens must belong to the same app, including after reconnect. */
8
+ export class ToolrollSlackSocket extends SocketModeClient {
9
+ expectedApp = "";
10
+ wrongApp = () => { };
11
+ async onWebSocketMessage(data, isBinary) {
12
+ let packet;
13
+ try {
14
+ packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ if (packet.type === "hello") {
20
+ const info = packet.connection_info;
21
+ if (info?.app_id !== this.expectedApp) {
22
+ this.wrongApp();
23
+ await this.disconnect();
24
+ return;
25
+ }
26
+ }
27
+ return super.onWebSocketMessage(data, isBinary);
28
+ }
29
+ }
package/dist/slack.d.ts CHANGED
@@ -1,11 +1,4 @@
1
- import { SocketModeClient } from "@slack/socket-mode";
2
1
  import { type SlackChatOptions } from "./slack-chat.js";
3
- /** The bot and socket tokens must belong to the same app, including after reconnect. */
4
- export declare class ToolrollSlackSocket extends SocketModeClient {
5
- expectedApp: string;
6
- wrongApp: () => void;
7
- protected onWebSocketMessage(data: string | ArrayBuffer, isBinary: boolean): Promise<void>;
8
- }
9
2
  export declare function followSlack(options: Omit<SlackChatOptions, "identity" | "api" | "owner" | "current"> & {
10
3
  dir: string;
11
4
  signal: AbortSignal;
package/dist/slack.js CHANGED
@@ -1,33 +1,9 @@
1
1
  /** One leased Socket Mode connection, hosted by the ordinary worker. */
2
2
  import { randomBytes } from "node:crypto";
3
3
  import { setTimeout as sleep } from "node:timers/promises";
4
- import { SocketModeClient, LogLevel } from "@slack/socket-mode";
5
4
  import { loadSlackCredentials, slackApi } from "./slack-api.js";
6
5
  import { ChatState } from "./chat-delivery-state.js";
7
6
  import { receiveSlack, processSlackEvent, deliverSlackPart, planSlackNotifications, planSlackRooms, } from "./slack-chat.js";
8
- /** The bot and socket tokens must belong to the same app, including after reconnect. */
9
- export class ToolrollSlackSocket extends SocketModeClient {
10
- expectedApp = "";
11
- wrongApp = () => { };
12
- async onWebSocketMessage(data, isBinary) {
13
- let packet;
14
- try {
15
- packet = JSON.parse(typeof data === "string" ? data : new TextDecoder().decode(data));
16
- }
17
- catch {
18
- return;
19
- }
20
- if (packet.type === "hello") {
21
- const info = packet.connection_info;
22
- if (info?.app_id !== this.expectedApp) {
23
- this.wrongApp();
24
- await this.disconnect();
25
- return;
26
- }
27
- }
28
- return super.onWebSocketMessage(data, isBinary);
29
- }
30
- }
31
7
  export async function followSlack(options) {
32
8
  const owner = randomBytes(16).toString("hex"), state = new ChatState(options.store, "slack");
33
9
  const pause = async (ms) => {
@@ -56,6 +32,8 @@ export async function followSlack(options) {
56
32
  state.owns(credentials.installation, owner));
57
33
  };
58
34
  const problem = (text) => state.setProblem(credentials.installation, owner, text);
35
+ // The SDK loads once Slack is set up, not with every command that could host the follower.
36
+ const { ToolrollSlackSocket, LogLevel } = await import("./slack-socket.js");
59
37
  // SDK log output can include envelopes/tokens. Only fixed status phrases are saved.
60
38
  const client = new ToolrollSlackSocket({
61
39
  appToken: credentials.appToken,
@@ -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, this process, and `extra`.
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) and the stage must
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;