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.
Files changed (118) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/agent-board.d.ts +208 -0
  3. package/dist/agent-board.js +454 -0
  4. package/dist/agent-fence.d.ts +19 -0
  5. package/dist/agent-fence.js +100 -8
  6. package/dist/agent-pulse.d.ts +43 -0
  7. package/dist/agent-pulse.js +214 -0
  8. package/dist/agent-turns.d.ts +52 -0
  9. package/dist/agent-turns.js +137 -0
  10. package/dist/agents-board.d.ts +101 -0
  11. package/dist/agents-board.js +225 -0
  12. package/dist/assignment-adapters.d.ts +1 -0
  13. package/dist/assignment-status.d.ts +1 -1
  14. package/dist/assignment.d.ts +4 -0
  15. package/dist/assignment.js +8 -6
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +29 -25
  18. package/dist/browser-crew.js +2 -0
  19. package/dist/browser-workspace.d.ts +79 -1
  20. package/dist/browser-workspace.js +1 -0
  21. package/dist/builder.d.ts +8 -0
  22. package/dist/builder.js +113 -13
  23. package/dist/chat-channel.js +9 -0
  24. package/dist/chat-core.d.ts +6 -0
  25. package/dist/chat-core.js +8 -0
  26. package/dist/chat-delivery.js +22 -0
  27. package/dist/chat-messages.js +8 -1
  28. package/dist/chat-quiet.js +1 -0
  29. package/dist/chat-voice.d.ts +2 -0
  30. package/dist/chat-voice.js +2 -0
  31. package/dist/checkout-cleanup.d.ts +3 -0
  32. package/dist/checkout-cleanup.js +11 -2
  33. package/dist/contracts/console-api.d.ts +3 -1
  34. package/dist/contracts/console-api.js +5 -1
  35. package/dist/contracts/lead-tools.d.ts +16 -2
  36. package/dist/contracts/lead-tools.js +5 -1
  37. package/dist/desktop-update.d.ts +1 -1
  38. package/dist/evidence-pack.js +1 -1
  39. package/dist/evidence.d.ts +6 -0
  40. package/dist/evidence.js +14 -3
  41. package/dist/exec.d.ts +32 -0
  42. package/dist/exec.js +229 -10
  43. package/dist/invoke.js +110 -18
  44. package/dist/landed-cleanup.d.ts +157 -0
  45. package/dist/landed-cleanup.js +707 -0
  46. package/dist/lead-cli.js +22 -19
  47. package/dist/lead-contract.js +1 -1
  48. package/dist/lead-doors.js +45 -1
  49. package/dist/lead-tools.js +40 -7
  50. package/dist/live-input.d.ts +68 -0
  51. package/dist/live-input.js +254 -0
  52. package/dist/live.d.ts +1 -1
  53. package/dist/live.js +4 -2
  54. package/dist/maintenance.d.ts +2 -0
  55. package/dist/maintenance.js +5 -2
  56. package/dist/operate-remote-arguments.js +5 -1
  57. package/dist/operate-remote.js +1 -1
  58. package/dist/operate.d.ts +1 -1
  59. package/dist/operate.js +188 -16
  60. package/dist/orphan-sweep.d.ts +2 -0
  61. package/dist/orphan-sweep.js +5 -3
  62. package/dist/project-delete-ui.d.ts +11 -0
  63. package/dist/project-delete-ui.js +29 -2
  64. package/dist/project-tools.d.ts +7 -11
  65. package/dist/project-tools.js +13 -37
  66. package/dist/provider.d.ts +65 -8
  67. package/dist/provider.js +80 -21
  68. package/dist/result-completion.d.ts +7 -0
  69. package/dist/result-completion.js +9 -0
  70. package/dist/runner.d.ts +4 -2
  71. package/dist/runner.js +3 -3
  72. package/dist/serve.js +19 -1
  73. package/dist/server/live.d.ts +3 -0
  74. package/dist/server/live.js +35 -2
  75. package/dist/server/pages.js +59 -35
  76. package/dist/server/render-chat.js +12 -0
  77. package/dist/server/render-tasks.d.ts +12 -2
  78. package/dist/server/render-tasks.js +27 -7
  79. package/dist/server/route-table.js +4 -1
  80. package/dist/server/settings.js +2 -1
  81. package/dist/server/task-views.js +9 -0
  82. package/dist/server/tasks.js +63 -0
  83. package/dist/shared-deps.d.ts +9 -4
  84. package/dist/shared-deps.js +89 -13
  85. package/dist/staged-runtimes.d.ts +16 -4
  86. package/dist/staged-runtimes.js +160 -38
  87. package/dist/steer-live.d.ts +19 -0
  88. package/dist/steer-live.js +93 -0
  89. package/dist/steer-status.d.ts +8 -2
  90. package/dist/steer-status.js +19 -5
  91. package/dist/storage-sweep-child.d.ts +1 -0
  92. package/dist/storage-sweep-child.js +76 -0
  93. package/dist/storage-sweep-process.d.ts +41 -0
  94. package/dist/storage-sweep-process.js +187 -0
  95. package/dist/storage-sweep.d.ts +30 -6
  96. package/dist/storage-sweep.js +214 -28
  97. package/dist/storage-ui.d.ts +2 -0
  98. package/dist/storage-ui.js +16 -4
  99. package/dist/storage.d.ts +6 -0
  100. package/dist/storage.js +35 -0
  101. package/dist/store.d.ts +163 -2
  102. package/dist/store.js +350 -15
  103. package/dist/surface.js +4 -2
  104. package/dist/sweep-quarantine.d.ts +28 -0
  105. package/dist/sweep-quarantine.js +153 -0
  106. package/dist/task-status.d.ts +2 -0
  107. package/dist/task-status.js +5 -1
  108. package/dist/test-temp.d.ts +6 -3
  109. package/dist/test-temp.js +15 -9
  110. package/dist/toolroll-update.js +3 -2
  111. package/dist/work-index.d.ts +1 -0
  112. package/dist/work-index.js +32 -12
  113. package/dist/work-summary.d.ts +2 -2
  114. package/dist/workspace-revision.js +3 -1
  115. package/dist/worktree.d.ts +3 -1
  116. package/dist/worktree.js +93 -10
  117. package/package.json +1 -1
  118. package/scripts/provider-canary.mjs +235 -147
package/dist/store.js CHANGED
@@ -11,6 +11,7 @@ import { trackWorkspaceWrites } from "./workspace-revision.js";
11
11
  import { parseStoreColumn, readStoreColumn, readStoreStringifiedList, readStoreTextList } from "./contracts/store-json.js";
12
12
  import { assessmentFromSavedEvidence, verificationEvidence } from "./verification-evidence.js";
13
13
  import { LEARNING_SCHEMA, queueLearning } from "./project-learning.js";
14
+ import { AGENT_BOARD_SCHEMA, AGENT_NOW_COLUMNS, closeRunQuestions, mintAttemptKey, watchedPing } from "./agent-board.js";
14
15
  import { SKILLS_SCHEMA } from "./project-skills.js";
15
16
  import { KNOWLEDGE_SCHEMA } from "./project-knowledge.js";
16
17
  import { MEMORY_SCHEMA } from "./project-memory.js";
@@ -50,7 +51,7 @@ import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose
50
51
  * are deferred until after M4, and a column that exists to serve a feature
51
52
  * nobody has designed yet is a column that will be wrong when they do.
52
53
  */
53
- import { createHash, randomBytes } from "node:crypto";
54
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
54
55
  import { existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
55
56
  import { createRequire } from "node:module";
56
57
  import { dirname } from "node:path";
@@ -83,7 +84,7 @@ import { REQUEST_BUDGET_SCHEMA } from "./request-budget.js";
83
84
  import { REVIEW_SCHEMA, reviewSwitchWords } from "./review-switch.js";
84
85
  import { SPEND_SCHEMA, billingOf, budgetStates, canPrice, claudeMachineBilling, countsToward, filersOf, monthOf, priceWork, seenBilling as seenBillingOf, spendItems, subagentFilers, usd } from "./spend.js";
85
86
  import { DEFAULT_PERIODS, RETENTION_SCHEMA, periodWords, widenRetentionSchema } from "./retention.js";
86
- import { CHECKOUT_CLEANUP_SCHEMA, STORAGE_SWEEP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
87
+ import { CHECKOUT_CLEANUP_SCHEMA, LANDED_CLEANUP_SCHEMA, STORAGE_SWEEP_SCHEMA, DEFAULT_CLEANUP, cleanupWords } from "./storage.js";
87
88
  import { IN_RANGE, LEDGER_CHAIN_SCHEMA, safeWhole, sealLedger, verifyLedgerChain } from "./ledger-chain.js";
88
89
  import { NO_RULES, filerFor, isProtectedWork, protectedChanges, rulesWords } from "./approval-policy.js";
89
90
  import { configBase, envValue, namedPath } from "./names.js";
@@ -354,6 +355,43 @@ CREATE TABLE IF NOT EXISTS run_activity (
354
355
  at TEXT NOT NULL
355
356
  );
356
357
  `;
358
+ /** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
359
+ * stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
360
+ * request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
361
+ * sends right after the interrupt. The builder's dispatcher stamps sent_at when the interrupt is written, paused_at when
362
+ * the turn stopped, resumed_at when a later note continued the session, and ended_at when the run stopped taking input
363
+ * first. Never a stop: nothing here ends, holds or reclaims an attempt (run_stop does that). A person's Resume (run_resume)
364
+ * continues a paused build with a short continue frame and no note: one row per request, bound to the exact paused run;
365
+ * sent_at when the frame was written, ended_at when the run was no longer paused to take it. */
366
+ export const LIVE_STEER_SCHEMA = `
367
+ CREATE TABLE IF NOT EXISTS run_pause (
368
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
369
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
370
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
371
+ request_id TEXT NOT NULL UNIQUE,
372
+ requested_by TEXT NOT NULL,
373
+ requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),
374
+ requested_at TEXT NOT NULL,
375
+ note INTEGER REFERENCES task_steer(id) ON DELETE SET NULL,
376
+ sent_at TEXT,
377
+ paused_at TEXT,
378
+ resumed_at TEXT,
379
+ ended_at TEXT
380
+ );
381
+ CREATE INDEX IF NOT EXISTS run_pause_by_run ON run_pause (run, id);
382
+ CREATE TABLE IF NOT EXISTS run_resume (
383
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
384
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
385
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
386
+ request_id TEXT NOT NULL UNIQUE,
387
+ requested_by TEXT NOT NULL,
388
+ requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),
389
+ requested_at TEXT NOT NULL,
390
+ sent_at TEXT,
391
+ ended_at TEXT
392
+ );
393
+ CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
394
+ `;
357
395
  export const CHAT_APPROVAL_SCHEMA = `
358
396
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
359
397
  approver TEXT NOT NULL,
@@ -564,7 +602,10 @@ const REGISTERED_AT_FILING = "again, as it was filed";
564
602
  // (decision.superseded_reason: its task's scope changed, or the task was cancelled).
565
603
  // v120 lets a running build read steering: a note the pulse put in the build's steering folder (task_steer.mailed_run,
566
604
  // mailed_at) and the moment the agent moved it to handled/ (acked_at).
567
- export const SCHEMA_VERSION = 120;
605
+ // v121 adds, and changes no saved row: the Agents board (run.attempt_key; agent_turn, agent_now, agent_question,
606
+ // agent_seen, agent_view), live steering (a note's live identity and sends, a run's stdin session; run_pause and
607
+ // run_resume) and landed work's teardown journal (landed_cleanup, landed_cleanup_item).
608
+ export const SCHEMA_VERSION = 121;
568
609
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
569
610
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
570
611
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -688,9 +729,10 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
688
729
  * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
689
730
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
690
731
  * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
691
- * three nullable task_steer columns (the running build's steering folder), checked the same way.
732
+ * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
733
+ * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way.
692
734
  */
693
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120]);
735
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121]);
694
736
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
695
737
  export const V119_ADDED_COLUMNS = Object.freeze({
696
738
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -710,6 +752,32 @@ export const V120_ADDED_COLUMNS = Object.freeze({
710
752
  steer_closed_at: { definition: "TEXT", saved: "NULL" },
711
753
  },
712
754
  });
755
+ /**
756
+ * Live steering's added columns (v121), with the value every row saved before them reads. A note carries the identity
757
+ * its stdin copy is sent under (live_uuid, minted when it is filed), how many times it was sent to the running build
758
+ * and when last, and when it was given up on as not picked up.
759
+ * A run says when its stdin session opened and closed: only then can a note reach it in seconds, or a Pause stop a turn;
760
+ * and how long a paused session may wait before it ends (the idle limit; none under a wall-clock limit).
761
+ */
762
+ export const LIVE_STEER_COLUMNS = Object.freeze({
763
+ task_steer: {
764
+ live_uuid: { definition: "TEXT", saved: "NULL" },
765
+ live_sends: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
766
+ live_sent_at: { definition: "TEXT", saved: "NULL" },
767
+ live_missed_at: { definition: "TEXT", saved: "NULL" },
768
+ },
769
+ run: {
770
+ live_input_opened_at: { definition: "TEXT", saved: "NULL" },
771
+ live_input_closed_at: { definition: "TEXT", saved: "NULL" },
772
+ live_pause_limit_ms: { definition: "INTEGER", saved: "NULL" },
773
+ },
774
+ });
775
+ /** v121: the columns it adds, by table, with the value every row saved before it reads: live steering's, and a run's
776
+ * attempt key (the Agents board; runs admitted before it read none). Its new tables carry no saved rows. */
777
+ export const V121_ADDED_COLUMNS = Object.freeze({
778
+ task_steer: LIVE_STEER_COLUMNS.task_steer,
779
+ run: { ...LIVE_STEER_COLUMNS.run, attempt_key: { definition: "TEXT", saved: "NULL" } },
780
+ });
713
781
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
714
782
  export function updateSafeSchema(version) {
715
783
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3440,6 +3508,11 @@ function initializeStore(db, file) {
3440
3508
  db.exec(CHAT_APPROVAL_SCHEMA);
3441
3509
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3442
3510
  db.exec(RUN_ACTIVITY_SCHEMA);
3511
+ // v121: the Agents board: each run's attempt key (V121_ADDED_COLUMNS, below), its turn history and current snapshot,
3512
+ // the agent's open questions, who has seen a finished run and who is watching one now. Older runs read null keys.
3513
+ db.exec(AGENT_BOARD_SCHEMA);
3514
+ for (const [column, definition] of AGENT_NOW_COLUMNS)
3515
+ addColumn(db, "agent_now", column, definition);
3443
3516
  // A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
3444
3517
  db.exec(FLOW_SEND_SCHEMA);
3445
3518
  db.exec(SPEND_SCHEMA);
@@ -3465,6 +3538,8 @@ function initializeStore(db, file) {
3465
3538
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
3466
3539
  // The daily storage sweep's record (no version bump: a new table, nothing carried over).
3467
3540
  db.exec(STORAGE_SWEEP_SCHEMA);
3541
+ // v121: landed work's teardown journal (landed-cleanup.ts): new tables.
3542
+ db.exec(LANDED_CLEANUP_SCHEMA);
3468
3543
  db.exec(BACKUP_SCHEMA);
3469
3544
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3470
3545
  db.exec(POLICY_SCHEMA);
@@ -3514,6 +3589,13 @@ function initializeStore(db, file) {
3514
3589
  for (const [column, { definition }] of Object.entries(columns))
3515
3590
  addColumn(db, table, column, definition);
3516
3591
  }
3592
+ // v121: live steering and the Agents board's attempt key (additive only; saved notes read as never sent, saved runs as
3593
+ // never taking input and keyless), and the pause and resume requests.
3594
+ for (const [table, columns] of Object.entries(V121_ADDED_COLUMNS)) {
3595
+ for (const [column, { definition }] of Object.entries(columns))
3596
+ addColumn(db, table, column, definition);
3597
+ }
3598
+ db.exec(LIVE_STEER_SCHEMA);
3517
3599
  installLedgerTriggers(db);
3518
3600
  // Attention/history indexes come AFTER migration: on a database whose
3519
3601
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -11765,6 +11847,8 @@ export class Store {
11765
11847
  // duplicate before any request is consumed.
11766
11848
  reviewAttempt);
11767
11849
  const id = Number(inserted.lastInsertRowid);
11850
+ // The attempt key (Agents board): opaque, minted with the row, and the only key its stream state is recorded under.
11851
+ this.db.prepare("UPDATE run SET attempt_key = ? WHERE id = ?").run(mintAttemptKey(), id);
11768
11852
  // The provenance row lands in the same transaction as the run row —
11769
11853
  // proved above, written here; a run and its route are one fact.
11770
11854
  this.writeRunRoute(id, stamp, run.now);
@@ -12080,6 +12164,8 @@ export class Store {
12080
12164
  // retained witnesses and every owned run; recovery revisits a pending
12081
12165
  // stop after an orphan finishes shutdown.
12082
12166
  this.recordRunProcessExits(id, result.now);
12167
+ // An ended attempt's open questions close unanswered: a reply would reach no one.
12168
+ closeRunQuestions(this, id, result.now);
12083
12169
  // Use the existing reconciliation proof now, while exits are fresh. In
12084
12170
  // particular, a returned --candidate check need not await the next scan.
12085
12171
  this.settleUnspawnedWitnesses(result.now, id);
@@ -12837,8 +12923,12 @@ export class Store {
12837
12923
  }
12838
12924
  /** What the agent did last on this run (live tasks): one fixed kind and when, replacing the last. */
12839
12925
  recordRunActivity(run, kind, now) {
12840
- this.db.prepare("INSERT INTO run_activity (run, kind, at) VALUES (?, ?, ?) ON CONFLICT (run) DO UPDATE SET kind = excluded.kind, at = excluded.at")
12841
- .run(run, kind, now.toISOString());
12926
+ this.transact(() => {
12927
+ this.db.prepare("INSERT INTO run_activity (run, kind, at) VALUES (?, ?, ?) ON CONFLICT (run) DO UPDATE SET kind = excluded.kind, at = excluded.at")
12928
+ .run(run, kind, now.toISOString());
12929
+ // Fresh activity is not Stuck: an open board hears the mark clear with this same write (agent-board.ts).
12930
+ this.db.prepare("UPDATE agent_now SET stuck_at = NULL WHERE run = ? AND stuck_at IS NOT NULL").run(run);
12931
+ });
12842
12932
  }
12843
12933
  /** A run's last recorded activity and its newest progress checkpoint's time, in one read. */
12844
12934
  runActivity(run) {
@@ -13014,13 +13104,19 @@ export class Store {
13014
13104
  const state = String(task["state"]);
13015
13105
  if (state === "done" || state === "cancelled")
13016
13106
  return { ok: false, reason: "task-finished" };
13017
- const inserted = this.db
13018
- .prepare("INSERT INTO task_steer (task_ref, author, note, created_at, authorship_state) VALUES (?, ?, ?, ?, 'verified')")
13019
- .run(Number(ref["id"]), author, valid.note, now.toISOString());
13107
+ const id = this.insertSteerNote(Number(ref["id"]), author, valid.note, now);
13020
13108
  this.bumpWake();
13021
- return { ok: true, id: Number(inserted.lastInsertRowid) };
13109
+ return { ok: true, id };
13022
13110
  }), result => result.ok);
13023
13111
  }
13112
+ /** One verified note, with the identity a running build's stdin copy is sent under: filing it is also what queues
13113
+ * its live delivery, in the same transaction (the builder's dispatcher sends whatever is filed and unsent). */
13114
+ insertSteerNote(taskRef, author, note, now) {
13115
+ const inserted = this.db
13116
+ .prepare("INSERT INTO task_steer (task_ref, author, note, created_at, authorship_state, live_uuid) VALUES (?, ?, ?, ?, 'verified', ?)")
13117
+ .run(taskRef, author, note, now.toISOString(), randomUUID());
13118
+ return Number(inserted.lastInsertRowid);
13119
+ }
13024
13120
  /** Every steering note of a task, filing order, delivery state included. */
13025
13121
  listSteerNotes(taskRef) {
13026
13122
  return this.db
@@ -13071,11 +13167,12 @@ export class Store {
13071
13167
  .all(taskRef, runId, live, live);
13072
13168
  const stamp = now.toISOString();
13073
13169
  for (const row of rows) {
13170
+ // A brief that quotes it starts its live delivery over: an earlier build's sends and give-up are not this one's.
13074
13171
  this.db
13075
- .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ? WHERE id = ?")
13172
+ .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ?, live_sends = 0, live_sent_at = NULL, live_missed_at = NULL WHERE id = ?")
13076
13173
  .run(runId, stamp, Number(row["id"]));
13077
13174
  }
13078
- return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp }));
13175
+ return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp, live_sends: 0, live_sent_at: null, live_missed_at: null }));
13079
13176
  });
13080
13177
  }
13081
13178
  /**
@@ -13135,9 +13232,12 @@ export class Store {
13135
13232
  * reading: delivery settles only on the agent's own acknowledgement. */
13136
13233
  markSteerMailed(noteId, runId, now) {
13137
13234
  const { changes } = this.db
13138
- .prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?
13235
+ .prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?,
13236
+ live_sends = CASE WHEN mailed_run IS ? THEN live_sends ELSE 0 END,
13237
+ live_sent_at = CASE WHEN mailed_run IS ? THEN live_sent_at ELSE NULL END,
13238
+ live_missed_at = CASE WHEN mailed_run IS ? THEN live_missed_at ELSE NULL END
13139
13239
  WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13140
- .run(runId, now.toISOString(), noteId);
13240
+ .run(runId, now.toISOString(), runId, runId, runId, noteId);
13141
13241
  return Number(changes) > 0;
13142
13242
  }
13143
13243
  /**
@@ -13156,6 +13256,201 @@ export class Store {
13156
13256
  this.bumpWake();
13157
13257
  return Number(changes) > 0;
13158
13258
  }
13259
+ /**
13260
+ * The task's build that takes live input right now (live steering): the steering build (steeringLiveBuild) whose
13261
+ * Claude session reads stdin. Only this run can take a note in seconds, or a Pause. Null otherwise.
13262
+ */
13263
+ liveInputRun(taskRef, now) {
13264
+ const run = this.steeringLiveBuild(taskRef, now);
13265
+ if (run === null)
13266
+ return null;
13267
+ const row = this.db.prepare("SELECT 1 FROM run WHERE id = ? AND live_input_opened_at IS NOT NULL AND live_input_closed_at IS NULL").get(run);
13268
+ return row === undefined ? null : run;
13269
+ }
13270
+ /** This run's Claude session reads stdin now (live steering), and how long a paused session may wait (null: no idle limit). */
13271
+ markLiveInputOpened(runId, now, pauseLimitMs = null) {
13272
+ this.db.prepare("UPDATE run SET live_input_opened_at = ?, live_pause_limit_ms = ? WHERE id = ? AND outcome IS NULL AND live_input_opened_at IS NULL")
13273
+ .run(now.toISOString(), pauseLimitMs === null || !Number.isSafeInteger(pauseLimitMs) || pauseLimitMs <= 0 ? null : pauseLimitMs, runId);
13274
+ }
13275
+ /** This run's stdin is closed (live steering): notes reach it only through its folder, and a Pause is over. */
13276
+ markLiveInputClosed(runId, now) {
13277
+ const stamp = now.toISOString();
13278
+ this.transact(() => {
13279
+ this.db.prepare("UPDATE run SET live_input_closed_at = ? WHERE id = ? AND live_input_opened_at IS NOT NULL AND live_input_closed_at IS NULL").run(stamp, runId);
13280
+ this.db.prepare("UPDATE run_pause SET ended_at = ? WHERE run = ? AND ended_at IS NULL AND resumed_at IS NULL").run(stamp, runId);
13281
+ this.db.prepare("UPDATE run_resume SET ended_at = ? WHERE run = ? AND sent_at IS NULL AND ended_at IS NULL").run(stamp, runId);
13282
+ });
13283
+ }
13284
+ /** One steering note by id. */
13285
+ steerNote(noteId) {
13286
+ const row = this.db.prepare("SELECT * FROM task_steer WHERE id = ?").get(noteId);
13287
+ return row === undefined ? null : readSteerNote(row);
13288
+ }
13289
+ /**
13290
+ * The note's copy was written to this run's stdin (live steering): one more send, stamped. Sending is not reading —
13291
+ * only the agent moving its file to handled/ is (ackSteerNote). Refused once the note is read, ended, given up on,
13292
+ * or mailed to another run.
13293
+ */
13294
+ recordLiveSend(noteId, runId, now) {
13295
+ const { changes } = this.db
13296
+ .prepare(`UPDATE task_steer SET live_sends = live_sends + 1, live_sent_at = ?
13297
+ WHERE id = ? AND mailed_run = ? AND delivered_at IS NULL AND superseded_at IS NULL AND live_missed_at IS NULL`)
13298
+ .run(now.toISOString(), noteId, runId);
13299
+ return Number(changes) > 0;
13300
+ }
13301
+ /** The running build was sent this note every time it is owed and never moved it to handled/: Not picked up. A later
13302
+ * acknowledgement still reads it (ackSteerNote ignores this), and an attempt that ends returns it to the next brief. */
13303
+ markLiveMissed(noteId, runId, now) {
13304
+ const { changes } = this.db
13305
+ .prepare("UPDATE task_steer SET live_missed_at = ? WHERE id = ? AND mailed_run = ? AND delivered_at IS NULL AND superseded_at IS NULL AND live_missed_at IS NULL")
13306
+ .run(now.toISOString(), noteId, runId);
13307
+ if (Number(changes) > 0)
13308
+ this.bumpWake();
13309
+ return Number(changes) > 0;
13310
+ }
13311
+ /**
13312
+ * Pause a running Claude build, or Pause and steer it (live steering): asked of the exact run the person saw, under
13313
+ * their request id. In one transaction the note (when there is one) is filed — Ruling 11's verified author — and the
13314
+ * pause is queued ahead of it, so the dispatcher writes the interrupt before the note. A repeated request id is the
13315
+ * same request; a run that no longer takes live input is stale. Pause never stops, holds or reclaims the attempt.
13316
+ */
13317
+ requestPause(request, now) {
13318
+ return this.transact(() => {
13319
+ const requestId = request.requestId.trim();
13320
+ if (!/^[A-Za-z0-9._:-]{8,100}$/.test(requestId))
13321
+ return { ok: false, reason: "invalid-request", detail: "the request id is 8 to 100 letters, digits or . _ : -" };
13322
+ const seen = this.db.prepare("SELECT * FROM run_pause WHERE request_id = ?").get(requestId);
13323
+ if (seen !== undefined) {
13324
+ const pause = readPause(seen);
13325
+ if (pause.run !== request.runId)
13326
+ return { ok: false, reason: "duplicate", detail: "that request id was already used for another attempt" };
13327
+ return { ok: true, pause: pause.id, note: pause.note, duplicate: true };
13328
+ }
13329
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(request.taskId);
13330
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, request.taskId);
13331
+ if (task === undefined || ref === undefined)
13332
+ return { ok: false, reason: "unknown-task", detail: "no such task" };
13333
+ const state = String(task["state"]);
13334
+ if (state === "done" || state === "cancelled")
13335
+ return { ok: false, reason: "task-finished", detail: "this task is finished" };
13336
+ const taskRef = Number(ref["id"]);
13337
+ if (this.liveInputRun(taskRef, now) !== request.runId) {
13338
+ return { ok: false, reason: "stale", detail: `build #${request.runId} isn't taking live input now — reload and decide against the current attempt` };
13339
+ }
13340
+ let noteId = null;
13341
+ if (request.note !== undefined) {
13342
+ const valid = validateNote(request.note);
13343
+ if (!valid.ok)
13344
+ return { ok: false, reason: "invalid-note", detail: valid.problem };
13345
+ noteId = this.insertSteerNote(taskRef, request.by, valid.note, now);
13346
+ }
13347
+ const inserted = this.db
13348
+ .prepare("INSERT INTO run_pause (run, task_ref, request_id, requested_by, requested_via, requested_at, note) VALUES (?, ?, ?, ?, ?, ?, ?)")
13349
+ .run(request.runId, taskRef, requestId, request.by, request.via, now.toISOString(), noteId);
13350
+ this.bumpWake();
13351
+ return { ok: true, pause: Number(inserted.lastInsertRowid), note: noteId, duplicate: false };
13352
+ });
13353
+ }
13354
+ /** The pauses this run has not yet written to stdin, in request order (the dispatcher writes these first). */
13355
+ unsentPauses(runId) {
13356
+ return this.db.prepare("SELECT * FROM run_pause WHERE run = ? AND sent_at IS NULL AND ended_at IS NULL ORDER BY id").all(runId).map(readPause);
13357
+ }
13358
+ /** The interrupt for this pause was written to the run's stdin. */
13359
+ markPauseSent(pauseId, now) {
13360
+ this.db.prepare("UPDATE run_pause SET sent_at = ? WHERE id = ? AND sent_at IS NULL AND ended_at IS NULL").run(now.toISOString(), pauseId);
13361
+ }
13362
+ /** The run's current turn stopped: every pause sent and still waiting reads paused. */
13363
+ markRunPaused(runId, now) {
13364
+ const changed = this.db.prepare("UPDATE run_pause SET paused_at = ? WHERE run = ? AND sent_at IS NOT NULL AND paused_at IS NULL AND resumed_at IS NULL AND ended_at IS NULL").run(now.toISOString(), runId);
13365
+ if (Number(changed.changes) > 0)
13366
+ this.bumpWake();
13367
+ }
13368
+ /** A note continued the paused session: its pauses are over. */
13369
+ markRunResumed(runId, now) {
13370
+ this.db.prepare("UPDATE run_pause SET resumed_at = ? WHERE run = ? AND sent_at IS NOT NULL AND resumed_at IS NULL AND ended_at IS NULL").run(now.toISOString(), runId);
13371
+ }
13372
+ /** The run could not take this pause (its stdin closed first). */
13373
+ endPause(pauseId, now) {
13374
+ this.db.prepare("UPDATE run_pause SET ended_at = ? WHERE id = ? AND ended_at IS NULL").run(now.toISOString(), pauseId);
13375
+ }
13376
+ /** Where this run's latest pause stands: asked and not yet stopped (pausing), stopped (paused), or none. */
13377
+ pauseStateOf(runId) {
13378
+ return this.livePauseOf(runId)?.state ?? null;
13379
+ }
13380
+ /** This run's latest pause, when it paused, and how long a paused build may wait before it ends; null with none. */
13381
+ livePauseOf(runId) {
13382
+ const row = this.db.prepare(`SELECT p.paused_at, r.live_pause_limit_ms FROM run_pause p JOIN run r ON r.id = p.run
13383
+ WHERE p.run = ? AND p.resumed_at IS NULL AND p.ended_at IS NULL ORDER BY p.id DESC LIMIT 1`).get(runId);
13384
+ if (row === undefined)
13385
+ return null;
13386
+ const pausedAt = row["paused_at"] === null ? null : String(row["paused_at"]);
13387
+ const limit = row["live_pause_limit_ms"] === null || row["live_pause_limit_ms"] === undefined ? null : Number(row["live_pause_limit_ms"]);
13388
+ return { state: pausedAt === null ? "pausing" : "paused", pausedAt, limitMs: limit };
13389
+ }
13390
+ /**
13391
+ * Resume a paused Claude build (live steering): asked of the exact run the person saw, under their request id, and
13392
+ * only while that run is paused. The dispatcher sends a short continue frame — never a note, so nothing is filed.
13393
+ * A repeated request id is the same request; a run that no longer takes live input is stale.
13394
+ */
13395
+ requestResume(request, now) {
13396
+ return this.transact(() => {
13397
+ const requestId = request.requestId.trim();
13398
+ if (!/^[A-Za-z0-9._:-]{8,100}$/.test(requestId))
13399
+ return { ok: false, reason: "invalid-request", detail: "the request id is 8 to 100 letters, digits or . _ : -" };
13400
+ const seen = this.db.prepare("SELECT id, run FROM run_resume WHERE request_id = ?").get(requestId);
13401
+ if (seen !== undefined) {
13402
+ if (Number(seen["run"]) !== request.runId)
13403
+ return { ok: false, reason: "duplicate", detail: "that request id was already used for another attempt" };
13404
+ return { ok: true, resume: Number(seen["id"]), duplicate: true };
13405
+ }
13406
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(request.taskId);
13407
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, request.taskId);
13408
+ if (task === undefined || ref === undefined)
13409
+ return { ok: false, reason: "unknown-task", detail: "no such task" };
13410
+ const state = String(task["state"]);
13411
+ if (state === "done" || state === "cancelled")
13412
+ return { ok: false, reason: "task-finished", detail: "this task is finished" };
13413
+ const taskRef = Number(ref["id"]);
13414
+ if (this.liveInputRun(taskRef, now) !== request.runId) {
13415
+ return { ok: false, reason: "stale", detail: `build #${request.runId} isn't taking live input now — reload and decide against the current attempt` };
13416
+ }
13417
+ if (this.pauseStateOf(request.runId) !== "paused")
13418
+ return { ok: false, reason: "not-paused", detail: `build #${request.runId} isn't paused` };
13419
+ const inserted = this.db
13420
+ .prepare("INSERT INTO run_resume (run, task_ref, request_id, requested_by, requested_via, requested_at) VALUES (?, ?, ?, ?, ?, ?)")
13421
+ .run(request.runId, taskRef, requestId, request.by, request.via, now.toISOString());
13422
+ this.bumpWake();
13423
+ return { ok: true, resume: Number(inserted.lastInsertRowid), duplicate: false };
13424
+ });
13425
+ }
13426
+ /** The resumes this run has not yet sent, in request order. */
13427
+ unsentResumes(runId) {
13428
+ return this.db.prepare("SELECT id, request_id FROM run_resume WHERE run = ? AND sent_at IS NULL AND ended_at IS NULL ORDER BY id").all(runId)
13429
+ .map(row => ({ id: Number(row["id"]), requestId: String(row["request_id"]) }));
13430
+ }
13431
+ /** The continue frame was written: the resume is sent, and the run's pauses are over. */
13432
+ markResumeSent(resumeId, runId, now) {
13433
+ this.transact(() => {
13434
+ this.db.prepare("UPDATE run_resume SET sent_at = ? WHERE id = ? AND sent_at IS NULL AND ended_at IS NULL").run(now.toISOString(), resumeId);
13435
+ this.markRunResumed(runId, now);
13436
+ });
13437
+ this.bumpWake();
13438
+ }
13439
+ /** The run was no longer paused (a note continued it first) or stopped taking input: nothing to send. */
13440
+ endResume(resumeId, now) {
13441
+ this.db.prepare("UPDATE run_resume SET ended_at = ? WHERE id = ? AND sent_at IS NULL AND ended_at IS NULL").run(now.toISOString(), resumeId);
13442
+ }
13443
+ /** Every resume of a task, newest first (history and tests). */
13444
+ resumesForTask(taskRef) {
13445
+ return this.db.prepare("SELECT * FROM run_resume WHERE task_ref = ? ORDER BY id DESC").all(taskRef).map(row => ({
13446
+ id: Number(row["id"]), run: Number(row["run"]), requestId: String(row["request_id"]), requestedVia: String(row["requested_via"]),
13447
+ sentAt: row["sent_at"] === null ? null : String(row["sent_at"]), endedAt: row["ended_at"] === null ? null : String(row["ended_at"]),
13448
+ }));
13449
+ }
13450
+ /** Every pause of a task, newest first (history and tests). */
13451
+ pausesForTask(taskRef) {
13452
+ return this.db.prepare("SELECT * FROM run_pause WHERE task_ref = ? ORDER BY id DESC").all(taskRef).map(readPause);
13453
+ }
13159
13454
  /** A finished task's pending notes settle superseded — shown, typed, done. */
13160
13455
  supersedeSteerNotes(taskRef, now) {
13161
13456
  const { changes } = this.db
@@ -17442,10 +17737,24 @@ export class Store {
17442
17737
  this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
17443
17738
  .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
17444
17739
  }
17740
+ else
17741
+ this.settleWatchedPush(Number(raw["pair"]), readNotification(raw), String(raw["approver"]), now);
17445
17742
  }
17446
17743
  }
17447
17744
  return Number(changes);
17448
17745
  }
17746
+ /** A pending pair about a run its person is watching waits for the watch to lapse; one about a finished run they have
17747
+ * since seen retires unsent (agent-board.ts watchedPing). False when the pair must not be sent now. */
17748
+ settleWatchedPush(pair, row, approver, now) {
17749
+ const decided = watchedPing(this, row, approver, now);
17750
+ if (decided === "send")
17751
+ return true;
17752
+ if (decided === "skip")
17753
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'seen' WHERE id = ? AND state = 'pending'").run(pair);
17754
+ else
17755
+ this.db.prepare("UPDATE push_delivery SET next_attempt_at = ? WHERE id = ? AND state = 'pending'").run(decided.hold, pair);
17756
+ return false;
17757
+ }
17449
17758
  /**
17450
17759
  * Claim due pairs: pending (respecting next_attempt_at) or claimed-but-
17451
17760
  * expired (reclaim bumps the generation — the old owner loses every
@@ -17464,6 +17773,11 @@ export class Store {
17464
17773
  .map(row => Number(row["id"]));
17465
17774
  const taken = [];
17466
17775
  for (const id of due) {
17776
+ // A ping held while its person watched the run is decided again now: held on, retired, or sent.
17777
+ const watched = this.db.prepare(`SELECT n.*, s.approver AS approver FROM push_delivery p JOIN push_subscription s ON s.id = p.subscription
17778
+ JOIN notification n ON n.id = p.notification WHERE p.id = ? AND p.state = 'pending'`).get(id);
17779
+ if (watched !== undefined && !this.settleWatchedPush(id, readNotification(watched), String(watched["approver"]), now))
17780
+ continue;
17467
17781
  const { changes } = this.db
17468
17782
  .prepare(`UPDATE push_delivery SET state = 'claimed', claim_owner = ?, claim_expires_at = ?,
17469
17783
  claim_generation = claim_generation + 1
@@ -17741,6 +18055,27 @@ function readSteerNote(row) {
17741
18055
  mailedRun: row["mailed_run"] === null || row["mailed_run"] === undefined ? null : Number(row["mailed_run"]),
17742
18056
  mailedAt: row["mailed_at"] === null || row["mailed_at"] === undefined ? null : String(row["mailed_at"]),
17743
18057
  ackedAt: row["acked_at"] === null || row["acked_at"] === undefined ? null : String(row["acked_at"]),
18058
+ liveUuid: row["live_uuid"] === null || row["live_uuid"] === undefined ? null : String(row["live_uuid"]),
18059
+ liveSends: Number(row["live_sends"] ?? 0),
18060
+ liveSentAt: row["live_sent_at"] === null || row["live_sent_at"] === undefined ? null : String(row["live_sent_at"]),
18061
+ liveMissedAt: row["live_missed_at"] === null || row["live_missed_at"] === undefined ? null : String(row["live_missed_at"]),
18062
+ };
18063
+ }
18064
+ function readPause(row) {
18065
+ const text = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
18066
+ return {
18067
+ id: Number(row["id"]),
18068
+ run: Number(row["run"]),
18069
+ taskRef: Number(row["task_ref"]),
18070
+ requestId: String(row["request_id"]),
18071
+ requestedBy: String(row["requested_by"]),
18072
+ requestedVia: String(row["requested_via"]),
18073
+ requestedAt: String(row["requested_at"]),
18074
+ note: row["note"] === null || row["note"] === undefined ? null : Number(row["note"]),
18075
+ sentAt: text("sent_at"),
18076
+ pausedAt: text("paused_at"),
18077
+ resumedAt: text("resumed_at"),
18078
+ endedAt: text("ended_at"),
17744
18079
  };
17745
18080
  }
17746
18081
  function readDecision(row) {
package/dist/surface.js CHANGED
@@ -242,7 +242,9 @@ const GUIDE_ROWS = [
242
242
  positionals: [{ name: "id", required: true, meaning: "the task" }],
243
243
  flags: [jsonFlag, dbFlag, keyFlag, { name: "undo", takesValue: false, meaning: "back to filing order" }],
244
244
  notableReasons: ["unknown-task", "claimed"] },
245
- operator("task steer", "one credentialed note for the task's agent — a running build reads it when it next checks for notes, otherwise the next attempt does; steering speaks with the operator's voice"),
245
+ operator("task steer", "one credentialed note for the task's agent — a running Claude build gets it in seconds, another running build reads it when it next checks for notes, otherwise the next attempt does; --now pauses the running Claude build first; steering speaks with the operator's voice"),
246
+ operator("task pause", "stop a running Claude build's current turn and keep the build (--run <id> names the attempt): its next note continues it; never a stop"),
247
+ operator("task continue", "resume a paused Claude build where it stopped, without a note (--run <id> names the attempt)"),
246
248
  { invocation: "task assign", synopsis: "reserve a task for one worker, or return it to the shared queue", audience: "agent", agentMayInvoke: true, mutation: "keyed",
247
249
  positionals: [{ name: "id", required: true, meaning: "the task" }],
248
250
  flags: [jsonFlag, dbFlag, keyFlag,
@@ -449,7 +451,7 @@ const REMOTE_STEP_UP = [
449
451
  const REMOTE_YES = [
450
452
  "status", "integrations", "ready", "gaps", "grants", "sync", "audit",
451
453
  "task add", "task ask", "task checks", "task add-tests", "task list", "task show", "check-progress", "task wait", "task complete", "task revise",
452
- "task state", "task block", "task unblock", "task next", "task steer", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
454
+ "task state", "task block", "task unblock", "task next", "task steer", "task pause", "task continue", "task assign", "task scope", "task plan", "task hold", "task unhold", "task require",
453
455
  "task requeue", "task review", "task repair", "task route", "task reopen", "task stop", "task resume",
454
456
  "runner list", "coordinator list", "cap list", "cap add", "outbox list", "incident list", "incident resolve",
455
457
  "config show", "verify show", "intake show", "intake run", "intake preview", "intake pr-comments", "template list", "template show",
@@ -0,0 +1,28 @@
1
+ export declare const QUARANTINE_NOTE = ".toolroll-sweep.json";
2
+ type Journal<T> = {
3
+ version: 1;
4
+ name: string;
5
+ data: T;
6
+ deleting: boolean;
7
+ pid: number;
8
+ host: string;
9
+ bootId: string | null;
10
+ };
11
+ export type Quarantine<T> = {
12
+ path: string;
13
+ original: string;
14
+ journal: Journal<T>;
15
+ };
16
+ export declare function quarantineEntries<T>(root: string): Quarantine<T>[];
17
+ /** A small marker closes discovery before the atomic rename. It is never published half-written. */
18
+ export declare function quarantine<T>(original: string, data: T): Quarantine<T> | null;
19
+ /** Returns null if another sweep owns it (or won recovery). */
20
+ export declare function claimQuarantine<T>(entry: Quarantine<T>): Quarantine<T> | null;
21
+ export declare function releaseQuarantine<T>(entry: Quarantine<T>): void;
22
+ /** A partially deleted tree must never become discoverable again. */
23
+ export declare function restoreQuarantine<T>(entry: Quarantine<T>): boolean;
24
+ /** Keep the journal until all potentially large removals succeeded. Recovery never guesses at a half-deleted tree. */
25
+ export declare function deleteQuarantine<T>(entry: Quarantine<T>, remove: (path: string) => void | Promise<void>): Promise<void>;
26
+ /** Staged pruning is also called synchronously by update/deploy. */
27
+ export declare function deleteQuarantineSync<T>(entry: Quarantine<T>, remove: (path: string) => void): void;
28
+ export {};