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
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,
@@ -562,7 +600,12 @@ const REGISTERED_AT_FILING = "again, as it was filed";
562
600
  // v119 keeps planning generations: a rewritten scope starts a clean planning context (task_ref, run and plan_revision
563
601
  // carry planning_generation, 0 for all earlier history), and a question closed without an answer says why
564
602
  // (decision.superseded_reason: its task's scope changed, or the task was cancelled).
565
- export const SCHEMA_VERSION = 119;
603
+ // v120 lets a running build read steering: a note the pulse put in the build's steering folder (task_steer.mailed_run,
604
+ // mailed_at) and the moment the agent moved it to handled/ (acked_at).
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;
566
609
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
567
610
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
568
611
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -685,9 +728,11 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
685
728
  * coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
686
729
  * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
687
730
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
688
- * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
731
+ * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns). v120 only adds
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.
689
734
  */
690
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119]);
735
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121]);
691
736
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
692
737
  export const V119_ADDED_COLUMNS = Object.freeze({
693
738
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -695,6 +740,44 @@ export const V119_ADDED_COLUMNS = Object.freeze({
695
740
  plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
696
741
  decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
697
742
  });
743
+ /** v120: the columns it adds, by table, with the value every row saved before it reads. */
744
+ export const V120_ADDED_COLUMNS = Object.freeze({
745
+ task_steer: {
746
+ mailed_run: { definition: "INTEGER REFERENCES run(id)", saved: "NULL" },
747
+ mailed_at: { definition: "TEXT", saved: "NULL" },
748
+ acked_at: { definition: "TEXT", saved: "NULL" },
749
+ },
750
+ run: {
751
+ steer_opened_at: { definition: "TEXT", saved: "NULL" },
752
+ steer_closed_at: { definition: "TEXT", saved: "NULL" },
753
+ },
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
+ });
698
781
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
699
782
  export function updateSafeSchema(version) {
700
783
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -2890,6 +2973,9 @@ CREATE TABLE IF NOT EXISTS review_request (
2890
2973
  -- rode toward; delivered_at settles only on the stream's own receipt (the
2891
2974
  -- agent's first words, or a successful completion). At-least-once, honestly
2892
2975
  -- labeled. Superseded means the task ended with the note still pending.
2976
+ -- v120 adds mailed_run, mailed_at and acked_at (V120_ADDED_COLUMNS): a note
2977
+ -- filed while a build runs is written into its steering folder, and is read
2978
+ -- (and delivered) only when that agent moves it to handled/.
2893
2979
  CREATE TABLE IF NOT EXISTS task_steer (
2894
2980
  id INTEGER PRIMARY KEY AUTOINCREMENT,
2895
2981
  task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
@@ -3422,6 +3508,11 @@ function initializeStore(db, file) {
3422
3508
  db.exec(CHAT_APPROVAL_SCHEMA);
3423
3509
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3424
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);
3425
3516
  // A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
3426
3517
  db.exec(FLOW_SEND_SCHEMA);
3427
3518
  db.exec(SPEND_SCHEMA);
@@ -3447,6 +3538,8 @@ function initializeStore(db, file) {
3447
3538
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
3448
3539
  // The daily storage sweep's record (no version bump: a new table, nothing carried over).
3449
3540
  db.exec(STORAGE_SWEEP_SCHEMA);
3541
+ // v121: landed work's teardown journal (landed-cleanup.ts): new tables.
3542
+ db.exec(LANDED_CLEANUP_SCHEMA);
3450
3543
  db.exec(BACKUP_SCHEMA);
3451
3544
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3452
3545
  db.exec(POLICY_SCHEMA);
@@ -3491,6 +3584,18 @@ function initializeStore(db, file) {
3491
3584
  for (const [column, { definition }] of Object.entries(columns))
3492
3585
  addColumn(db, table, column, definition);
3493
3586
  }
3587
+ // v120: a running build's steering folder (additive only; saved notes read as never mailed).
3588
+ for (const [table, columns] of Object.entries(V120_ADDED_COLUMNS)) {
3589
+ for (const [column, { definition }] of Object.entries(columns))
3590
+ addColumn(db, table, column, definition);
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);
3494
3599
  installLedgerTriggers(db);
3495
3600
  // Attention/history indexes come AFTER migration: on a database whose
3496
3601
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -11742,6 +11847,8 @@ export class Store {
11742
11847
  // duplicate before any request is consumed.
11743
11848
  reviewAttempt);
11744
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);
11745
11852
  // The provenance row lands in the same transaction as the run row —
11746
11853
  // proved above, written here; a run and its route are one fact.
11747
11854
  this.writeRunRoute(id, stamp, run.now);
@@ -12057,6 +12164,8 @@ export class Store {
12057
12164
  // retained witnesses and every owned run; recovery revisits a pending
12058
12165
  // stop after an orphan finishes shutdown.
12059
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);
12060
12169
  // Use the existing reconciliation proof now, while exits are fresh. In
12061
12170
  // particular, a returned --candidate check need not await the next scan.
12062
12171
  this.settleUnspawnedWitnesses(result.now, id);
@@ -12814,8 +12923,12 @@ export class Store {
12814
12923
  }
12815
12924
  /** What the agent did last on this run (live tasks): one fixed kind and when, replacing the last. */
12816
12925
  recordRunActivity(run, kind, now) {
12817
- this.db.prepare("INSERT INTO run_activity (run, kind, at) VALUES (?, ?, ?) ON CONFLICT (run) DO UPDATE SET kind = excluded.kind, at = excluded.at")
12818
- .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
+ });
12819
12932
  }
12820
12933
  /** A run's last recorded activity and its newest progress checkpoint's time, in one read. */
12821
12934
  runActivity(run) {
@@ -12991,13 +13104,19 @@ export class Store {
12991
13104
  const state = String(task["state"]);
12992
13105
  if (state === "done" || state === "cancelled")
12993
13106
  return { ok: false, reason: "task-finished" };
12994
- const inserted = this.db
12995
- .prepare("INSERT INTO task_steer (task_ref, author, note, created_at, authorship_state) VALUES (?, ?, ?, ?, 'verified')")
12996
- .run(Number(ref["id"]), author, valid.note, now.toISOString());
13107
+ const id = this.insertSteerNote(Number(ref["id"]), author, valid.note, now);
12997
13108
  this.bumpWake();
12998
- return { ok: true, id: Number(inserted.lastInsertRowid) };
13109
+ return { ok: true, id };
12999
13110
  }), result => result.ok);
13000
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
+ }
13001
13120
  /** Every steering note of a task, filing order, delivery state included. */
13002
13121
  listSteerNotes(taskRef) {
13003
13122
  return this.db
@@ -13048,11 +13167,12 @@ export class Store {
13048
13167
  .all(taskRef, runId, live, live);
13049
13168
  const stamp = now.toISOString();
13050
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.
13051
13171
  this.db
13052
- .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 = ?")
13053
13173
  .run(runId, stamp, Number(row["id"]));
13054
13174
  }
13055
- 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 }));
13056
13176
  });
13057
13177
  }
13058
13178
  /**
@@ -13067,6 +13187,270 @@ export class Store {
13067
13187
  .run(now.toISOString(), runId);
13068
13188
  return Number(changes);
13069
13189
  }
13190
+ /**
13191
+ * The task's build whose agent is running right now and reads its steering
13192
+ * folder (v120): the current live lease's open builder run, in its
13193
+ * agent-running phase, whose steering folder is open. Null otherwise — a
13194
+ * note then waits for the next attempt. Display state, never evidence.
13195
+ */
13196
+ steeringLiveBuild(taskRef, now) {
13197
+ const live = this.currentLiveLease(taskRef, now);
13198
+ if (live === null)
13199
+ return null;
13200
+ const row = this.db
13201
+ .prepare(`SELECT id FROM run WHERE task_ref = ? AND lease_id = ? AND role = 'builder'
13202
+ AND outcome IS NULL AND phase = 'agent-running' AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL
13203
+ ORDER BY id DESC LIMIT 1`)
13204
+ .get(taskRef, live);
13205
+ return row === undefined ? null : Number(row["id"]);
13206
+ }
13207
+ /** This run made its steering folder (v120): its agent can read a note filed now. */
13208
+ markSteerFolderOpened(runId, now) {
13209
+ this.db.prepare("UPDATE run SET steer_opened_at = ? WHERE id = ? AND outcome IS NULL AND steer_opened_at IS NULL").run(now.toISOString(), runId);
13210
+ }
13211
+ /** This run's steering folder is gone (v120): a note filed now reaches the next attempt. */
13212
+ markSteerFolderClosed(runId, now) {
13213
+ this.db.prepare("UPDATE run SET steer_closed_at = ? WHERE id = ? AND steer_opened_at IS NOT NULL AND steer_closed_at IS NULL").run(now.toISOString(), runId);
13214
+ }
13215
+ /**
13216
+ * The notes a RUNNING build may still read (v120): verified, pending, not
13217
+ * already quoted in this run's brief, and not already in its folder. The
13218
+ * pulse mails exactly these, in filing order.
13219
+ */
13220
+ mailableSteerNotes(taskRef, runId) {
13221
+ return this.db
13222
+ .prepare(`SELECT * FROM task_steer
13223
+ WHERE task_ref = ? AND authorship_state = 'verified'
13224
+ AND delivered_at IS NULL AND superseded_at IS NULL
13225
+ AND (attached_run IS NULL OR attached_run <> ?)
13226
+ AND (mailed_run IS NULL OR mailed_run <> ?)
13227
+ ORDER BY id`)
13228
+ .all(taskRef, runId, runId)
13229
+ .map(readSteerNote);
13230
+ }
13231
+ /** The pulse wrote this note into the run's steering folder. Mailing is not
13232
+ * reading: delivery settles only on the agent's own acknowledgement. */
13233
+ markSteerMailed(noteId, runId, now) {
13234
+ const { changes } = this.db
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
13239
+ WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13240
+ .run(runId, now.toISOString(), runId, runId, runId, noteId);
13241
+ return Number(changes) > 0;
13242
+ }
13243
+ /**
13244
+ * The agent moved this note to handled/ (v120): it was read, by THIS run.
13245
+ * Acknowledgement is the only road from mailed to delivered — a hook firing
13246
+ * or a file written proves nothing. First acknowledgement wins; a note the
13247
+ * task already ended, or mailed to another run since, is left alone.
13248
+ */
13249
+ ackSteerNote(noteId, runId, now) {
13250
+ const stamp = now.toISOString();
13251
+ const { changes } = this.db
13252
+ .prepare(`UPDATE task_steer SET acked_at = ?, delivered_at = ?, attached_run = ?, attached_at = COALESCE(mailed_at, ?)
13253
+ WHERE id = ? AND mailed_run = ? AND acked_at IS NULL AND delivered_at IS NULL AND superseded_at IS NULL`)
13254
+ .run(stamp, stamp, runId, stamp, noteId, runId);
13255
+ if (Number(changes) > 0)
13256
+ this.bumpWake();
13257
+ return Number(changes) > 0;
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
+ }
13070
13454
  /** A finished task's pending notes settle superseded — shown, typed, done. */
13071
13455
  supersedeSteerNotes(taskRef, now) {
13072
13456
  const { changes } = this.db
@@ -17353,10 +17737,24 @@ export class Store {
17353
17737
  this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
17354
17738
  .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
17355
17739
  }
17740
+ else
17741
+ this.settleWatchedPush(Number(raw["pair"]), readNotification(raw), String(raw["approver"]), now);
17356
17742
  }
17357
17743
  }
17358
17744
  return Number(changes);
17359
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
+ }
17360
17758
  /**
17361
17759
  * Claim due pairs: pending (respecting next_attempt_at) or claimed-but-
17362
17760
  * expired (reclaim bumps the generation — the old owner loses every
@@ -17375,6 +17773,11 @@ export class Store {
17375
17773
  .map(row => Number(row["id"]));
17376
17774
  const taken = [];
17377
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;
17378
17781
  const { changes } = this.db
17379
17782
  .prepare(`UPDATE push_delivery SET state = 'claimed', claim_owner = ?, claim_expires_at = ?,
17380
17783
  claim_generation = claim_generation + 1
@@ -17649,6 +18052,30 @@ function readSteerNote(row) {
17649
18052
  supersededAt: row["superseded_at"] === null ? null : String(row["superseded_at"]),
17650
18053
  authorshipState: row["authorship_state"] === "verified" ? "verified" : "unverified-legacy",
17651
18054
  supersededReason: row["superseded_reason"] === null || row["superseded_reason"] === undefined ? null : String(row["superseded_reason"]),
18055
+ mailedRun: row["mailed_run"] === null || row["mailed_run"] === undefined ? null : Number(row["mailed_run"]),
18056
+ mailedAt: row["mailed_at"] === null || row["mailed_at"] === undefined ? null : String(row["mailed_at"]),
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"),
17652
18079
  };
17653
18080
  }
17654
18081
  function readDecision(row) {
package/dist/surface.js CHANGED
@@ -201,7 +201,7 @@ const GUIDE_ROWS = [
201
201
  ...["show", "updates", "claim", "check", "brief", "inbox", "ack"].map(action => ({
202
202
  invocation: `assignment ${action}`,
203
203
  synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
204
- : action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
204
+ : action === "updates" ? "read the history of handoffs sent to people after a cursor; delivers nothing — receive updates with assignment inbox"
205
205
  : action === "claim" ? "record lead ownership; with the lead token the task reads \"<the lead's name> is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
206
206
  : action === "brief" ? "catch up from current assignments and project knowledge in the local database"
207
207
  : action === "inbox" ? "receive or replay a saved batch of lead status updates"
@@ -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 read before the task's next attempt — 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,10 @@
1
+ /** Assignment sweeps re-project every owned root on each pass. A pass is
2
+ * skipped when nothing has moved since the last full cycle began: the store's
3
+ * wake sequence is unchanged and that cycle started under a minute ago. The
4
+ * minute covers changes that don't bump wake (time-based lease expiry, some
5
+ * ledger-only writes). A cycle in progress (its cursor past 0) always goes on,
6
+ * and a restarted process sweeps at once. */
7
+ import type { Store } from "./store.js";
8
+ export declare const SWEEP_FALLBACK_MS = 60000;
9
+ /** Runs `sweep` unless the cycle under `cursorKey` would see what its last start saw. Returns whether it ran. */
10
+ export declare function sweepIfMoved(store: Store, cursorKey: string, now: Date, sweep: () => void): boolean;