toolroll 0.9.60 → 0.9.62

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 (128) hide show
  1. package/CHANGELOG.md +32 -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/cli.js +28 -11
  34. package/dist/contracts/console-api.d.ts +3 -1
  35. package/dist/contracts/console-api.js +5 -1
  36. package/dist/contracts/lead-tools.d.ts +16 -2
  37. package/dist/contracts/lead-tools.js +5 -1
  38. package/dist/desktop-update.d.ts +1 -1
  39. package/dist/evidence-pack.js +1 -1
  40. package/dist/evidence.d.ts +6 -0
  41. package/dist/evidence.js +14 -3
  42. package/dist/exec.d.ts +32 -0
  43. package/dist/exec.js +229 -10
  44. package/dist/invoke.js +110 -18
  45. package/dist/landed-cleanup.d.ts +170 -0
  46. package/dist/landed-cleanup.js +814 -0
  47. package/dist/lead-cli.js +22 -19
  48. package/dist/lead-contract.d.ts +1 -1
  49. package/dist/lead-contract.js +4 -4
  50. package/dist/lead-doors.js +45 -1
  51. package/dist/lead-tools.js +40 -7
  52. package/dist/live-input.d.ts +68 -0
  53. package/dist/live-input.js +254 -0
  54. package/dist/live.d.ts +1 -1
  55. package/dist/live.js +4 -2
  56. package/dist/maintenance.d.ts +2 -0
  57. package/dist/maintenance.js +5 -2
  58. package/dist/operate-remote-arguments.js +5 -1
  59. package/dist/operate-remote.js +1 -1
  60. package/dist/operate.d.ts +1 -1
  61. package/dist/operate.js +249 -26
  62. package/dist/orphan-sweep.d.ts +2 -0
  63. package/dist/orphan-sweep.js +5 -3
  64. package/dist/project-admission.d.ts +10 -6
  65. package/dist/project-admission.js +33 -11
  66. package/dist/project-delete-ui.d.ts +12 -0
  67. package/dist/project-delete-ui.js +32 -2
  68. package/dist/project-tools.d.ts +7 -11
  69. package/dist/project-tools.js +13 -37
  70. package/dist/provider.d.ts +65 -8
  71. package/dist/provider.js +80 -21
  72. package/dist/remote-main.d.ts +57 -0
  73. package/dist/remote-main.js +179 -0
  74. package/dist/result-completion.d.ts +7 -0
  75. package/dist/result-completion.js +9 -0
  76. package/dist/runner.d.ts +4 -2
  77. package/dist/runner.js +3 -3
  78. package/dist/serve.js +19 -1
  79. package/dist/server/live.d.ts +3 -0
  80. package/dist/server/live.js +35 -2
  81. package/dist/server/pages.js +59 -35
  82. package/dist/server/render-chat.js +12 -0
  83. package/dist/server/render-tasks.d.ts +15 -2
  84. package/dist/server/render-tasks.js +30 -7
  85. package/dist/server/route-table.js +4 -1
  86. package/dist/server/settings.js +5 -3
  87. package/dist/server/task-views.js +11 -0
  88. package/dist/server/tasks.js +63 -0
  89. package/dist/shared-deps.d.ts +9 -4
  90. package/dist/shared-deps.js +89 -13
  91. package/dist/staged-runtimes.d.ts +16 -4
  92. package/dist/staged-runtimes.js +160 -38
  93. package/dist/steer-live.d.ts +19 -0
  94. package/dist/steer-live.js +93 -0
  95. package/dist/steer-status.d.ts +8 -2
  96. package/dist/steer-status.js +19 -5
  97. package/dist/storage-sweep-child.d.ts +1 -0
  98. package/dist/storage-sweep-child.js +76 -0
  99. package/dist/storage-sweep-process.d.ts +48 -0
  100. package/dist/storage-sweep-process.js +204 -0
  101. package/dist/storage-sweep-schedule.d.ts +30 -0
  102. package/dist/storage-sweep-schedule.js +164 -0
  103. package/dist/storage-sweep.d.ts +31 -6
  104. package/dist/storage-sweep.js +165 -39
  105. package/dist/storage-ui.d.ts +4 -0
  106. package/dist/storage-ui.js +22 -7
  107. package/dist/storage.d.ts +6 -0
  108. package/dist/storage.js +35 -0
  109. package/dist/store.d.ts +200 -3
  110. package/dist/store.js +443 -17
  111. package/dist/surface.js +8 -4
  112. package/dist/sweep-quarantine.d.ts +28 -0
  113. package/dist/sweep-quarantine.js +153 -0
  114. package/dist/task-base.d.ts +35 -0
  115. package/dist/task-base.js +51 -0
  116. package/dist/task-status.d.ts +2 -0
  117. package/dist/task-status.js +5 -1
  118. package/dist/test-temp.d.ts +6 -3
  119. package/dist/test-temp.js +15 -9
  120. package/dist/toolroll-update.js +4 -2
  121. package/dist/work-index.d.ts +1 -0
  122. package/dist/work-index.js +32 -12
  123. package/dist/work-summary.d.ts +2 -2
  124. package/dist/workspace-revision.js +3 -1
  125. package/dist/worktree.d.ts +57 -3
  126. package/dist/worktree.js +201 -23
  127. package/package.json +1 -1
  128. 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,59 @@ CREATE TABLE IF NOT EXISTS run_activity (
354
355
  at TEXT NOT NULL
355
356
  );
356
357
  `;
358
+ /** A task branch moved to current main before an attempt (fresh base; v122, a new table: additive only): the branch
359
+ * had no commits of its own and no changes, so it started over from `to_rev`. `after_run` is the newest run id when it
360
+ * moved: the branch's whole-task diff runs from the first builder base recorded after it (firstBuilderBase), never from
361
+ * the older main it left. */
362
+ export const BRANCH_BASE_MOVE_SCHEMA = `
363
+ CREATE TABLE IF NOT EXISTS branch_base_move (
364
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
365
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
366
+ branch TEXT NOT NULL,
367
+ from_rev TEXT NOT NULL,
368
+ to_rev TEXT NOT NULL,
369
+ after_run INTEGER NOT NULL,
370
+ moved_at TEXT NOT NULL
371
+ );
372
+ CREATE INDEX IF NOT EXISTS branch_base_move_branch ON branch_base_move (task_ref, branch, id);
373
+ `;
374
+ /** Pausing a running Claude build (live steering, v121: additive only). A person's Pause asks the build to
375
+ * stop its current turn and keep its session: one row per request, bound to the exact run it was asked of, under the
376
+ * request id the asker chose (a repeated request is the same request). `note` is the steering note a Pause and steer
377
+ * sends right after the interrupt. The builder's dispatcher stamps sent_at when the interrupt is written, paused_at when
378
+ * the turn stopped, resumed_at when a later note continued the session, and ended_at when the run stopped taking input
379
+ * first. Never a stop: nothing here ends, holds or reclaims an attempt (run_stop does that). A person's Resume (run_resume)
380
+ * continues a paused build with a short continue frame and no note: one row per request, bound to the exact paused run;
381
+ * sent_at when the frame was written, ended_at when the run was no longer paused to take it. */
382
+ export const LIVE_STEER_SCHEMA = `
383
+ CREATE TABLE IF NOT EXISTS run_pause (
384
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
385
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
386
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
387
+ request_id TEXT NOT NULL UNIQUE,
388
+ requested_by TEXT NOT NULL,
389
+ requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),
390
+ requested_at TEXT NOT NULL,
391
+ note INTEGER REFERENCES task_steer(id) ON DELETE SET NULL,
392
+ sent_at TEXT,
393
+ paused_at TEXT,
394
+ resumed_at TEXT,
395
+ ended_at TEXT
396
+ );
397
+ CREATE INDEX IF NOT EXISTS run_pause_by_run ON run_pause (run, id);
398
+ CREATE TABLE IF NOT EXISTS run_resume (
399
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
400
+ run INTEGER NOT NULL REFERENCES run(id) ON DELETE CASCADE,
401
+ task_ref INTEGER NOT NULL REFERENCES task_ref(id) ON DELETE CASCADE,
402
+ request_id TEXT NOT NULL UNIQUE,
403
+ requested_by TEXT NOT NULL,
404
+ requested_via TEXT NOT NULL CHECK (requested_via IN ('cli','web','chat')),
405
+ requested_at TEXT NOT NULL,
406
+ sent_at TEXT,
407
+ ended_at TEXT
408
+ );
409
+ CREATE INDEX IF NOT EXISTS run_resume_by_run ON run_resume (run, id);
410
+ `;
357
411
  export const CHAT_APPROVAL_SCHEMA = `
358
412
  CREATE TABLE IF NOT EXISTS chat_approval_setting (
359
413
  approver TEXT NOT NULL,
@@ -564,7 +618,13 @@ const REGISTERED_AT_FILING = "again, as it was filed";
564
618
  // (decision.superseded_reason: its task's scope changed, or the task was cancelled).
565
619
  // v120 lets a running build read steering: a note the pulse put in the build's steering folder (task_steer.mailed_run,
566
620
  // mailed_at) and the moment the agent moved it to handled/ (acked_at).
567
- export const SCHEMA_VERSION = 120;
621
+ // v121 adds, and changes no saved row: the Agents board (run.attempt_key; agent_turn, agent_now, agent_question,
622
+ // agent_seen, agent_view), live steering (a note's live identity and sends, a run's stdin session; run_pause and
623
+ // run_resume) and landed work's teardown journal (landed_cleanup, landed_cleanup_item).
624
+ // v122 adds, and changes no saved row: the remote URL an owner's project add recorded (project.remote_url), the only
625
+ // remote whose main automatic completion trusts (remote-main.ts; saved projects read none until it is recorded), and
626
+ // fresh base's record of a task branch moved to current main before an attempt (branch_base_move).
627
+ export const SCHEMA_VERSION = 122;
568
628
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
569
629
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
570
630
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -688,9 +748,12 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
688
748
  * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
689
749
  * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
690
750
  * 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.
751
+ * three nullable task_steer columns (the running build's steering folder), checked the same way. v121 only adds
752
+ * new tables and the columns in V121_ADDED_COLUMNS (null, or no sends), checked the same way. v122 only adds a new
753
+ * table (branch_base_move) and the nullable column in V122_ADDED_COLUMNS (a project's recorded remote), checked the
754
+ * same way.
692
755
  */
693
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120]);
756
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122]);
694
757
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
695
758
  export const V119_ADDED_COLUMNS = Object.freeze({
696
759
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -710,6 +773,37 @@ export const V120_ADDED_COLUMNS = Object.freeze({
710
773
  steer_closed_at: { definition: "TEXT", saved: "NULL" },
711
774
  },
712
775
  });
776
+ /**
777
+ * Live steering's added columns (v121), with the value every row saved before them reads. A note carries the identity
778
+ * its stdin copy is sent under (live_uuid, minted when it is filed), how many times it was sent to the running build
779
+ * and when last, and when it was given up on as not picked up.
780
+ * 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;
781
+ * and how long a paused session may wait before it ends (the idle limit; none under a wall-clock limit).
782
+ */
783
+ export const LIVE_STEER_COLUMNS = Object.freeze({
784
+ task_steer: {
785
+ live_uuid: { definition: "TEXT", saved: "NULL" },
786
+ live_sends: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" },
787
+ live_sent_at: { definition: "TEXT", saved: "NULL" },
788
+ live_missed_at: { definition: "TEXT", saved: "NULL" },
789
+ },
790
+ run: {
791
+ live_input_opened_at: { definition: "TEXT", saved: "NULL" },
792
+ live_input_closed_at: { definition: "TEXT", saved: "NULL" },
793
+ live_pause_limit_ms: { definition: "INTEGER", saved: "NULL" },
794
+ },
795
+ });
796
+ /** v121: the columns it adds, by table, with the value every row saved before it reads: live steering's, and a run's
797
+ * attempt key (the Agents board; runs admitted before it read none). Its new tables carry no saved rows. */
798
+ export const V121_ADDED_COLUMNS = Object.freeze({
799
+ task_steer: LIVE_STEER_COLUMNS.task_steer,
800
+ run: { ...LIVE_STEER_COLUMNS.run, attempt_key: { definition: "TEXT", saved: "NULL" } },
801
+ });
802
+ /** v122: the column it adds, with the value every row saved before it reads: the remote URL an owner's project add
803
+ * recorded (projects added before it read none, until `repos add <project> --remote <url>` records one). */
804
+ export const V122_ADDED_COLUMNS = Object.freeze({
805
+ project: { remote_url: { definition: "TEXT", saved: "NULL" } },
806
+ });
713
807
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
714
808
  export function updateSafeSchema(version) {
715
809
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -3440,6 +3534,13 @@ function initializeStore(db, file) {
3440
3534
  db.exec(CHAT_APPROVAL_SCHEMA);
3441
3535
  // What a running agent did last (no version bump: additive only; absent means no word yet).
3442
3536
  db.exec(RUN_ACTIVITY_SCHEMA);
3537
+ // v122: a task branch moved to current main before an attempt (fresh base): a new table, nothing carried over.
3538
+ db.exec(BRANCH_BASE_MOVE_SCHEMA);
3539
+ // v121: the Agents board: each run's attempt key (V121_ADDED_COLUMNS, below), its turn history and current snapshot,
3540
+ // the agent's open questions, who has seen a finished run and who is watching one now. Older runs read null keys.
3541
+ db.exec(AGENT_BOARD_SCHEMA);
3542
+ for (const [column, definition] of AGENT_NOW_COLUMNS)
3543
+ addColumn(db, "agent_now", column, definition);
3443
3544
  // A flow's "Send to me" and "Person chooses" zones (no version bump: additive only).
3444
3545
  db.exec(FLOW_SEND_SCHEMA);
3445
3546
  db.exec(SPEND_SCHEMA);
@@ -3465,6 +3566,14 @@ function initializeStore(db, file) {
3465
3566
  db.exec(CHECKOUT_CLEANUP_SCHEMA);
3466
3567
  // The daily storage sweep's record (no version bump: a new table, nothing carried over).
3467
3568
  db.exec(STORAGE_SWEEP_SCHEMA);
3569
+ // v121: landed work's teardown journal (landed-cleanup.ts): new tables.
3570
+ db.exec(LANDED_CLEANUP_SCHEMA);
3571
+ // v122: the remote URL an owner's project add recorded (V122_ADDED_COLUMNS; additive only, saved projects read none):
3572
+ // automatic completion trusts only that remote's main (remote-main.ts), never the shared repository's config.
3573
+ for (const [table, columns] of Object.entries(V122_ADDED_COLUMNS)) {
3574
+ for (const [column, { definition }] of Object.entries(columns))
3575
+ addColumn(db, table, column, definition);
3576
+ }
3468
3577
  db.exec(BACKUP_SCHEMA);
3469
3578
  // Sprint 8: the organisation policy (one row, or none: nothing restricted).
3470
3579
  db.exec(POLICY_SCHEMA);
@@ -3514,6 +3623,13 @@ function initializeStore(db, file) {
3514
3623
  for (const [column, { definition }] of Object.entries(columns))
3515
3624
  addColumn(db, table, column, definition);
3516
3625
  }
3626
+ // v121: live steering and the Agents board's attempt key (additive only; saved notes read as never sent, saved runs as
3627
+ // never taking input and keyless), and the pause and resume requests.
3628
+ for (const [table, columns] of Object.entries(V121_ADDED_COLUMNS)) {
3629
+ for (const [column, { definition }] of Object.entries(columns))
3630
+ addColumn(db, table, column, definition);
3631
+ }
3632
+ db.exec(LIVE_STEER_SCHEMA);
3517
3633
  installLedgerTriggers(db);
3518
3634
  // Attention/history indexes come AFTER migration: on a database whose
3519
3635
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -3630,6 +3746,8 @@ function createBaseline(db, file) {
3630
3746
  }
3631
3747
  db.exec(BASELINE_SCHEMA);
3632
3748
  db.prepare("INSERT INTO schema_version (version) VALUES (?)").run(BASELINE_SCHEMA_VERSION);
3749
+ const installedAt = new Date().toISOString();
3750
+ db.prepare("INSERT INTO installation_fact (key, value, created_at) VALUES ('installed-at', ?, ?)").run(installedAt, installedAt);
3633
3751
  db.exec("COMMIT");
3634
3752
  }
3635
3753
  catch (error) {
@@ -11765,6 +11883,8 @@ export class Store {
11765
11883
  // duplicate before any request is consumed.
11766
11884
  reviewAttempt);
11767
11885
  const id = Number(inserted.lastInsertRowid);
11886
+ // The attempt key (Agents board): opaque, minted with the row, and the only key its stream state is recorded under.
11887
+ this.db.prepare("UPDATE run SET attempt_key = ? WHERE id = ?").run(mintAttemptKey(), id);
11768
11888
  // The provenance row lands in the same transaction as the run row —
11769
11889
  // proved above, written here; a run and its route are one fact.
11770
11890
  this.writeRunRoute(id, stamp, run.now);
@@ -12056,13 +12176,46 @@ export class Store {
12056
12176
  * legacy first-attempt behavior, unchanged byte-for-byte.
12057
12177
  */
12058
12178
  firstBuilderBase(taskRef, branch) {
12179
+ // A branch moved to current main (fresh base) had no commits of its own:
12180
+ // its origin is the first base recorded after the move, not the older main.
12059
12181
  const row = this.db
12060
12182
  .prepare(`SELECT base_revision FROM run
12061
12183
  WHERE task_ref = ? AND branch = ? AND role = 'builder' AND base_revision IS NOT NULL
12184
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12062
12185
  ORDER BY id ASC LIMIT 1`)
12063
- .get(taskRef, branch);
12186
+ .get(taskRef, branch, taskRef, branch);
12187
+ return row === undefined ? null : row.base_revision;
12188
+ }
12189
+ /** Where a task branch's attempts start now: a builder branch's pinned base, or the newest base any other attempt on
12190
+ * it recorded (a planner never commits). Null before any attempt on the branch recorded one, or after a move to
12191
+ * current main until the next attempt records its base. */
12192
+ recordedBranchBase(taskRef, branch) {
12193
+ const pinned = this.firstBuilderBase(taskRef, branch);
12194
+ if (pinned !== null)
12195
+ return pinned;
12196
+ const row = this.db
12197
+ .prepare(`SELECT base_revision FROM run
12198
+ WHERE task_ref = ? AND branch = ? AND role <> 'builder' AND base_revision IS NOT NULL
12199
+ AND id > COALESCE((SELECT after_run FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1), 0)
12200
+ ORDER BY id DESC LIMIT 1`)
12201
+ .get(taskRef, branch, taskRef, branch);
12064
12202
  return row === undefined ? null : row.base_revision;
12065
12203
  }
12204
+ /** Record a task branch's move to current main, before git moves it: a move that then fails leaves the next
12205
+ * attempt's own base (still the old one) as the branch's origin, which is exactly right. */
12206
+ recordBranchBaseMove(taskRef, branch, from, to, now) {
12207
+ this.db
12208
+ .prepare(`INSERT INTO branch_base_move (task_ref, branch, from_rev, to_rev, after_run, moved_at)
12209
+ VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(id), 0) FROM run), ?)`)
12210
+ .run(taskRef, branch, from, to, now.toISOString());
12211
+ }
12212
+ /** The newest move of a task branch to current main, or null. */
12213
+ latestBranchBaseMove(taskRef, branch) {
12214
+ const row = this.db
12215
+ .prepare("SELECT from_rev, to_rev, after_run, moved_at FROM branch_base_move WHERE task_ref = ? AND branch = ? ORDER BY id DESC LIMIT 1")
12216
+ .get(taskRef, branch);
12217
+ return row === undefined ? null : { from: String(row["from_rev"]), to: String(row["to_rev"]), afterRun: Number(row["after_run"]), movedAt: String(row["moved_at"]) };
12218
+ }
12066
12219
  finishRun(id, result) {
12067
12220
  this.transact(() => {
12068
12221
  // Read before the write (Telegram task updates): only the OPEN → closed
@@ -12080,6 +12233,8 @@ export class Store {
12080
12233
  // retained witnesses and every owned run; recovery revisits a pending
12081
12234
  // stop after an orphan finishes shutdown.
12082
12235
  this.recordRunProcessExits(id, result.now);
12236
+ // An ended attempt's open questions close unanswered: a reply would reach no one.
12237
+ closeRunQuestions(this, id, result.now);
12083
12238
  // Use the existing reconciliation proof now, while exits are fresh. In
12084
12239
  // particular, a returned --candidate check need not await the next scan.
12085
12240
  this.settleUnspawnedWitnesses(result.now, id);
@@ -12837,8 +12992,12 @@ export class Store {
12837
12992
  }
12838
12993
  /** What the agent did last on this run (live tasks): one fixed kind and when, replacing the last. */
12839
12994
  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());
12995
+ this.transact(() => {
12996
+ this.db.prepare("INSERT INTO run_activity (run, kind, at) VALUES (?, ?, ?) ON CONFLICT (run) DO UPDATE SET kind = excluded.kind, at = excluded.at")
12997
+ .run(run, kind, now.toISOString());
12998
+ // Fresh activity is not Stuck: an open board hears the mark clear with this same write (agent-board.ts).
12999
+ this.db.prepare("UPDATE agent_now SET stuck_at = NULL WHERE run = ? AND stuck_at IS NOT NULL").run(run);
13000
+ });
12842
13001
  }
12843
13002
  /** A run's last recorded activity and its newest progress checkpoint's time, in one read. */
12844
13003
  runActivity(run) {
@@ -13014,13 +13173,19 @@ export class Store {
13014
13173
  const state = String(task["state"]);
13015
13174
  if (state === "done" || state === "cancelled")
13016
13175
  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());
13176
+ const id = this.insertSteerNote(Number(ref["id"]), author, valid.note, now);
13020
13177
  this.bumpWake();
13021
- return { ok: true, id: Number(inserted.lastInsertRowid) };
13178
+ return { ok: true, id };
13022
13179
  }), result => result.ok);
13023
13180
  }
13181
+ /** One verified note, with the identity a running build's stdin copy is sent under: filing it is also what queues
13182
+ * its live delivery, in the same transaction (the builder's dispatcher sends whatever is filed and unsent). */
13183
+ insertSteerNote(taskRef, author, note, now) {
13184
+ const inserted = this.db
13185
+ .prepare("INSERT INTO task_steer (task_ref, author, note, created_at, authorship_state, live_uuid) VALUES (?, ?, ?, ?, 'verified', ?)")
13186
+ .run(taskRef, author, note, now.toISOString(), randomUUID());
13187
+ return Number(inserted.lastInsertRowid);
13188
+ }
13024
13189
  /** Every steering note of a task, filing order, delivery state included. */
13025
13190
  listSteerNotes(taskRef) {
13026
13191
  return this.db
@@ -13071,11 +13236,12 @@ export class Store {
13071
13236
  .all(taskRef, runId, live, live);
13072
13237
  const stamp = now.toISOString();
13073
13238
  for (const row of rows) {
13239
+ // A brief that quotes it starts its live delivery over: an earlier build's sends and give-up are not this one's.
13074
13240
  this.db
13075
- .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ? WHERE id = ?")
13241
+ .prepare("UPDATE task_steer SET attached_run = ?, attached_at = ?, live_sends = 0, live_sent_at = NULL, live_missed_at = NULL WHERE id = ?")
13076
13242
  .run(runId, stamp, Number(row["id"]));
13077
13243
  }
13078
- return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp }));
13244
+ return rows.map(row => readSteerNote({ ...row, attached_run: runId, attached_at: stamp, live_sends: 0, live_sent_at: null, live_missed_at: null }));
13079
13245
  });
13080
13246
  }
13081
13247
  /**
@@ -13135,9 +13301,12 @@ export class Store {
13135
13301
  * reading: delivery settles only on the agent's own acknowledgement. */
13136
13302
  markSteerMailed(noteId, runId, now) {
13137
13303
  const { changes } = this.db
13138
- .prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?
13304
+ .prepare(`UPDATE task_steer SET mailed_run = ?, mailed_at = ?,
13305
+ live_sends = CASE WHEN mailed_run IS ? THEN live_sends ELSE 0 END,
13306
+ live_sent_at = CASE WHEN mailed_run IS ? THEN live_sent_at ELSE NULL END,
13307
+ live_missed_at = CASE WHEN mailed_run IS ? THEN live_missed_at ELSE NULL END
13139
13308
  WHERE id = ? AND delivered_at IS NULL AND superseded_at IS NULL`)
13140
- .run(runId, now.toISOString(), noteId);
13309
+ .run(runId, now.toISOString(), runId, runId, runId, noteId);
13141
13310
  return Number(changes) > 0;
13142
13311
  }
13143
13312
  /**
@@ -13156,6 +13325,201 @@ export class Store {
13156
13325
  this.bumpWake();
13157
13326
  return Number(changes) > 0;
13158
13327
  }
13328
+ /**
13329
+ * The task's build that takes live input right now (live steering): the steering build (steeringLiveBuild) whose
13330
+ * Claude session reads stdin. Only this run can take a note in seconds, or a Pause. Null otherwise.
13331
+ */
13332
+ liveInputRun(taskRef, now) {
13333
+ const run = this.steeringLiveBuild(taskRef, now);
13334
+ if (run === null)
13335
+ return null;
13336
+ 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);
13337
+ return row === undefined ? null : run;
13338
+ }
13339
+ /** This run's Claude session reads stdin now (live steering), and how long a paused session may wait (null: no idle limit). */
13340
+ markLiveInputOpened(runId, now, pauseLimitMs = null) {
13341
+ 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")
13342
+ .run(now.toISOString(), pauseLimitMs === null || !Number.isSafeInteger(pauseLimitMs) || pauseLimitMs <= 0 ? null : pauseLimitMs, runId);
13343
+ }
13344
+ /** This run's stdin is closed (live steering): notes reach it only through its folder, and a Pause is over. */
13345
+ markLiveInputClosed(runId, now) {
13346
+ const stamp = now.toISOString();
13347
+ this.transact(() => {
13348
+ 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);
13349
+ this.db.prepare("UPDATE run_pause SET ended_at = ? WHERE run = ? AND ended_at IS NULL AND resumed_at IS NULL").run(stamp, runId);
13350
+ this.db.prepare("UPDATE run_resume SET ended_at = ? WHERE run = ? AND sent_at IS NULL AND ended_at IS NULL").run(stamp, runId);
13351
+ });
13352
+ }
13353
+ /** One steering note by id. */
13354
+ steerNote(noteId) {
13355
+ const row = this.db.prepare("SELECT * FROM task_steer WHERE id = ?").get(noteId);
13356
+ return row === undefined ? null : readSteerNote(row);
13357
+ }
13358
+ /**
13359
+ * The note's copy was written to this run's stdin (live steering): one more send, stamped. Sending is not reading —
13360
+ * only the agent moving its file to handled/ is (ackSteerNote). Refused once the note is read, ended, given up on,
13361
+ * or mailed to another run.
13362
+ */
13363
+ recordLiveSend(noteId, runId, now) {
13364
+ const { changes } = this.db
13365
+ .prepare(`UPDATE task_steer SET live_sends = live_sends + 1, live_sent_at = ?
13366
+ WHERE id = ? AND mailed_run = ? AND delivered_at IS NULL AND superseded_at IS NULL AND live_missed_at IS NULL`)
13367
+ .run(now.toISOString(), noteId, runId);
13368
+ return Number(changes) > 0;
13369
+ }
13370
+ /** The running build was sent this note every time it is owed and never moved it to handled/: Not picked up. A later
13371
+ * acknowledgement still reads it (ackSteerNote ignores this), and an attempt that ends returns it to the next brief. */
13372
+ markLiveMissed(noteId, runId, now) {
13373
+ const { changes } = this.db
13374
+ .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")
13375
+ .run(now.toISOString(), noteId, runId);
13376
+ if (Number(changes) > 0)
13377
+ this.bumpWake();
13378
+ return Number(changes) > 0;
13379
+ }
13380
+ /**
13381
+ * Pause a running Claude build, or Pause and steer it (live steering): asked of the exact run the person saw, under
13382
+ * their request id. In one transaction the note (when there is one) is filed — Ruling 11's verified author — and the
13383
+ * pause is queued ahead of it, so the dispatcher writes the interrupt before the note. A repeated request id is the
13384
+ * same request; a run that no longer takes live input is stale. Pause never stops, holds or reclaims the attempt.
13385
+ */
13386
+ requestPause(request, now) {
13387
+ return this.transact(() => {
13388
+ const requestId = request.requestId.trim();
13389
+ if (!/^[A-Za-z0-9._:-]{8,100}$/.test(requestId))
13390
+ return { ok: false, reason: "invalid-request", detail: "the request id is 8 to 100 letters, digits or . _ : -" };
13391
+ const seen = this.db.prepare("SELECT * FROM run_pause WHERE request_id = ?").get(requestId);
13392
+ if (seen !== undefined) {
13393
+ const pause = readPause(seen);
13394
+ if (pause.run !== request.runId)
13395
+ return { ok: false, reason: "duplicate", detail: "that request id was already used for another attempt" };
13396
+ return { ok: true, pause: pause.id, note: pause.note, duplicate: true };
13397
+ }
13398
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(request.taskId);
13399
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, request.taskId);
13400
+ if (task === undefined || ref === undefined)
13401
+ return { ok: false, reason: "unknown-task", detail: "no such task" };
13402
+ const state = String(task["state"]);
13403
+ if (state === "done" || state === "cancelled")
13404
+ return { ok: false, reason: "task-finished", detail: "this task is finished" };
13405
+ const taskRef = Number(ref["id"]);
13406
+ if (this.liveInputRun(taskRef, now) !== request.runId) {
13407
+ return { ok: false, reason: "stale", detail: `build #${request.runId} isn't taking live input now — reload and decide against the current attempt` };
13408
+ }
13409
+ let noteId = null;
13410
+ if (request.note !== undefined) {
13411
+ const valid = validateNote(request.note);
13412
+ if (!valid.ok)
13413
+ return { ok: false, reason: "invalid-note", detail: valid.problem };
13414
+ noteId = this.insertSteerNote(taskRef, request.by, valid.note, now);
13415
+ }
13416
+ const inserted = this.db
13417
+ .prepare("INSERT INTO run_pause (run, task_ref, request_id, requested_by, requested_via, requested_at, note) VALUES (?, ?, ?, ?, ?, ?, ?)")
13418
+ .run(request.runId, taskRef, requestId, request.by, request.via, now.toISOString(), noteId);
13419
+ this.bumpWake();
13420
+ return { ok: true, pause: Number(inserted.lastInsertRowid), note: noteId, duplicate: false };
13421
+ });
13422
+ }
13423
+ /** The pauses this run has not yet written to stdin, in request order (the dispatcher writes these first). */
13424
+ unsentPauses(runId) {
13425
+ 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);
13426
+ }
13427
+ /** The interrupt for this pause was written to the run's stdin. */
13428
+ markPauseSent(pauseId, now) {
13429
+ 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);
13430
+ }
13431
+ /** The run's current turn stopped: every pause sent and still waiting reads paused. */
13432
+ markRunPaused(runId, now) {
13433
+ 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);
13434
+ if (Number(changed.changes) > 0)
13435
+ this.bumpWake();
13436
+ }
13437
+ /** A note continued the paused session: its pauses are over. */
13438
+ markRunResumed(runId, now) {
13439
+ 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);
13440
+ }
13441
+ /** The run could not take this pause (its stdin closed first). */
13442
+ endPause(pauseId, now) {
13443
+ this.db.prepare("UPDATE run_pause SET ended_at = ? WHERE id = ? AND ended_at IS NULL").run(now.toISOString(), pauseId);
13444
+ }
13445
+ /** Where this run's latest pause stands: asked and not yet stopped (pausing), stopped (paused), or none. */
13446
+ pauseStateOf(runId) {
13447
+ return this.livePauseOf(runId)?.state ?? null;
13448
+ }
13449
+ /** This run's latest pause, when it paused, and how long a paused build may wait before it ends; null with none. */
13450
+ livePauseOf(runId) {
13451
+ 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
13452
+ WHERE p.run = ? AND p.resumed_at IS NULL AND p.ended_at IS NULL ORDER BY p.id DESC LIMIT 1`).get(runId);
13453
+ if (row === undefined)
13454
+ return null;
13455
+ const pausedAt = row["paused_at"] === null ? null : String(row["paused_at"]);
13456
+ const limit = row["live_pause_limit_ms"] === null || row["live_pause_limit_ms"] === undefined ? null : Number(row["live_pause_limit_ms"]);
13457
+ return { state: pausedAt === null ? "pausing" : "paused", pausedAt, limitMs: limit };
13458
+ }
13459
+ /**
13460
+ * Resume a paused Claude build (live steering): asked of the exact run the person saw, under their request id, and
13461
+ * only while that run is paused. The dispatcher sends a short continue frame — never a note, so nothing is filed.
13462
+ * A repeated request id is the same request; a run that no longer takes live input is stale.
13463
+ */
13464
+ requestResume(request, now) {
13465
+ return this.transact(() => {
13466
+ const requestId = request.requestId.trim();
13467
+ if (!/^[A-Za-z0-9._:-]{8,100}$/.test(requestId))
13468
+ return { ok: false, reason: "invalid-request", detail: "the request id is 8 to 100 letters, digits or . _ : -" };
13469
+ const seen = this.db.prepare("SELECT id, run FROM run_resume WHERE request_id = ?").get(requestId);
13470
+ if (seen !== undefined) {
13471
+ if (Number(seen["run"]) !== request.runId)
13472
+ return { ok: false, reason: "duplicate", detail: "that request id was already used for another attempt" };
13473
+ return { ok: true, resume: Number(seen["id"]), duplicate: true };
13474
+ }
13475
+ const task = this.db.prepare("SELECT state FROM task WHERE id = ?").get(request.taskId);
13476
+ const ref = this.db.prepare("SELECT id FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, request.taskId);
13477
+ if (task === undefined || ref === undefined)
13478
+ return { ok: false, reason: "unknown-task", detail: "no such task" };
13479
+ const state = String(task["state"]);
13480
+ if (state === "done" || state === "cancelled")
13481
+ return { ok: false, reason: "task-finished", detail: "this task is finished" };
13482
+ const taskRef = Number(ref["id"]);
13483
+ if (this.liveInputRun(taskRef, now) !== request.runId) {
13484
+ return { ok: false, reason: "stale", detail: `build #${request.runId} isn't taking live input now — reload and decide against the current attempt` };
13485
+ }
13486
+ if (this.pauseStateOf(request.runId) !== "paused")
13487
+ return { ok: false, reason: "not-paused", detail: `build #${request.runId} isn't paused` };
13488
+ const inserted = this.db
13489
+ .prepare("INSERT INTO run_resume (run, task_ref, request_id, requested_by, requested_via, requested_at) VALUES (?, ?, ?, ?, ?, ?)")
13490
+ .run(request.runId, taskRef, requestId, request.by, request.via, now.toISOString());
13491
+ this.bumpWake();
13492
+ return { ok: true, resume: Number(inserted.lastInsertRowid), duplicate: false };
13493
+ });
13494
+ }
13495
+ /** The resumes this run has not yet sent, in request order. */
13496
+ unsentResumes(runId) {
13497
+ 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)
13498
+ .map(row => ({ id: Number(row["id"]), requestId: String(row["request_id"]) }));
13499
+ }
13500
+ /** The continue frame was written: the resume is sent, and the run's pauses are over. */
13501
+ markResumeSent(resumeId, runId, now) {
13502
+ this.transact(() => {
13503
+ 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);
13504
+ this.markRunResumed(runId, now);
13505
+ });
13506
+ this.bumpWake();
13507
+ }
13508
+ /** The run was no longer paused (a note continued it first) or stopped taking input: nothing to send. */
13509
+ endResume(resumeId, now) {
13510
+ 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);
13511
+ }
13512
+ /** Every resume of a task, newest first (history and tests). */
13513
+ resumesForTask(taskRef) {
13514
+ return this.db.prepare("SELECT * FROM run_resume WHERE task_ref = ? ORDER BY id DESC").all(taskRef).map(row => ({
13515
+ id: Number(row["id"]), run: Number(row["run"]), requestId: String(row["request_id"]), requestedVia: String(row["requested_via"]),
13516
+ sentAt: row["sent_at"] === null ? null : String(row["sent_at"]), endedAt: row["ended_at"] === null ? null : String(row["ended_at"]),
13517
+ }));
13518
+ }
13519
+ /** Every pause of a task, newest first (history and tests). */
13520
+ pausesForTask(taskRef) {
13521
+ return this.db.prepare("SELECT * FROM run_pause WHERE task_ref = ? ORDER BY id DESC").all(taskRef).map(readPause);
13522
+ }
13159
13523
  /** A finished task's pending notes settle superseded — shown, typed, done. */
13160
13524
  supersedeSteerNotes(taskRef, now) {
13161
13525
  const { changes } = this.db
@@ -15299,17 +15663,38 @@ export class Store {
15299
15663
  // ---- projects ------------------------------------------------------------
15300
15664
  /** Remember a project was opened. Upsert keeps added_at; recency always moves.
15301
15665
  * A project added for the first time starts on Quick checks. */
15302
- upsertProject(path, name, now) {
15666
+ upsertProject(path, name, now, admission) {
15303
15667
  this.transact(() => {
15304
15668
  const added = this.db.prepare("SELECT 1 FROM project WHERE path = ?").get(path) === undefined;
15305
15669
  this.db
15306
15670
  .prepare(`INSERT INTO project (path, name, added_at, last_opened_at) VALUES (?, ?, ?, ?)
15307
15671
  ON CONFLICT (path) DO UPDATE SET name = excluded.name, last_opened_at = excluded.last_opened_at`)
15308
15672
  .run(path, name, now.toISOString(), now.toISOString());
15673
+ // Admission owns remote changes; ordinary openings preserve the saved value.
15674
+ if (admission !== undefined)
15675
+ this.db.prepare("UPDATE project SET remote_url = ? WHERE path = ?").run(admission.remoteUrl, path);
15309
15676
  if (added)
15310
15677
  stampNewProjectLevel(this, path, now);
15311
15678
  });
15312
15679
  }
15680
+ /** Includes removed/legacy projects, so deleting a row never makes build-writable config authoritative again. */
15681
+ projectAdmissionKnown(path) {
15682
+ return this.db.prepare(`SELECT 1 FROM project WHERE path = ?
15683
+ UNION ALL SELECT 1 FROM action_ledger WHERE repo = ?
15684
+ UNION ALL SELECT 1 FROM task_ref WHERE repo = ?
15685
+ UNION ALL SELECT 1 FROM worktree WHERE repo = ? LIMIT 1`).get(path, path, path, path) !== undefined;
15686
+ }
15687
+ /** Full structured URL audit, without the generic ledger's display-text truncation. */
15688
+ recordProjectRemote(actor, repo, previous, remoteUrl, now) {
15689
+ this.db.prepare(`INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail)
15690
+ VALUES (?,?,?,NULL,NULL,'project remote recorded','recorded','access',?)`)
15691
+ .run(now.toISOString(), actor, repo, JSON.stringify({ previousRemote: previous, remoteUrl }));
15692
+ }
15693
+ /** The remote URL captured on admission or explicitly supplied by the owner; null when none was (or the project was removed). */
15694
+ projectRemote(path) {
15695
+ const url = this.db.prepare("SELECT remote_url FROM project WHERE path = ?").get(path)?.["remote_url"];
15696
+ return typeof url === "string" && url !== "" ? url : null;
15697
+ }
15313
15698
  /** A project removed from the lists (project-admission.ts): only its row goes; its tasks, results and settings stay. */
15314
15699
  forgetProject(path) {
15315
15700
  this.db.prepare("DELETE FROM project WHERE path = ?").run(path);
@@ -15324,6 +15709,7 @@ export class Store {
15324
15709
  name: String(row["name"]),
15325
15710
  addedAt: String(row["added_at"]),
15326
15711
  lastOpenedAt: String(row["last_opened_at"]),
15712
+ remoteUrl: typeof row["remote_url"] === "string" ? row["remote_url"] : null,
15327
15713
  }));
15328
15714
  }
15329
15715
  /**
@@ -17442,10 +17828,24 @@ export class Store {
17442
17828
  this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = ? WHERE id = ? AND state = 'pending'")
17443
17829
  .run(elsewhere ? TELEGRAM_SKIPPED_OTHER_CHAT : "quiet", Number(raw["pair"]));
17444
17830
  }
17831
+ else
17832
+ this.settleWatchedPush(Number(raw["pair"]), readNotification(raw), String(raw["approver"]), now);
17445
17833
  }
17446
17834
  }
17447
17835
  return Number(changes);
17448
17836
  }
17837
+ /** A pending pair about a run its person is watching waits for the watch to lapse; one about a finished run they have
17838
+ * since seen retires unsent (agent-board.ts watchedPing). False when the pair must not be sent now. */
17839
+ settleWatchedPush(pair, row, approver, now) {
17840
+ const decided = watchedPing(this, row, approver, now);
17841
+ if (decided === "send")
17842
+ return true;
17843
+ if (decided === "skip")
17844
+ this.db.prepare("UPDATE push_delivery SET state = 'retired', last_error = 'seen' WHERE id = ? AND state = 'pending'").run(pair);
17845
+ else
17846
+ this.db.prepare("UPDATE push_delivery SET next_attempt_at = ? WHERE id = ? AND state = 'pending'").run(decided.hold, pair);
17847
+ return false;
17848
+ }
17449
17849
  /**
17450
17850
  * Claim due pairs: pending (respecting next_attempt_at) or claimed-but-
17451
17851
  * expired (reclaim bumps the generation — the old owner loses every
@@ -17464,6 +17864,11 @@ export class Store {
17464
17864
  .map(row => Number(row["id"]));
17465
17865
  const taken = [];
17466
17866
  for (const id of due) {
17867
+ // A ping held while its person watched the run is decided again now: held on, retired, or sent.
17868
+ const watched = this.db.prepare(`SELECT n.*, s.approver AS approver FROM push_delivery p JOIN push_subscription s ON s.id = p.subscription
17869
+ JOIN notification n ON n.id = p.notification WHERE p.id = ? AND p.state = 'pending'`).get(id);
17870
+ if (watched !== undefined && !this.settleWatchedPush(id, readNotification(watched), String(watched["approver"]), now))
17871
+ continue;
17467
17872
  const { changes } = this.db
17468
17873
  .prepare(`UPDATE push_delivery SET state = 'claimed', claim_owner = ?, claim_expires_at = ?,
17469
17874
  claim_generation = claim_generation + 1
@@ -17741,6 +18146,27 @@ function readSteerNote(row) {
17741
18146
  mailedRun: row["mailed_run"] === null || row["mailed_run"] === undefined ? null : Number(row["mailed_run"]),
17742
18147
  mailedAt: row["mailed_at"] === null || row["mailed_at"] === undefined ? null : String(row["mailed_at"]),
17743
18148
  ackedAt: row["acked_at"] === null || row["acked_at"] === undefined ? null : String(row["acked_at"]),
18149
+ liveUuid: row["live_uuid"] === null || row["live_uuid"] === undefined ? null : String(row["live_uuid"]),
18150
+ liveSends: Number(row["live_sends"] ?? 0),
18151
+ liveSentAt: row["live_sent_at"] === null || row["live_sent_at"] === undefined ? null : String(row["live_sent_at"]),
18152
+ liveMissedAt: row["live_missed_at"] === null || row["live_missed_at"] === undefined ? null : String(row["live_missed_at"]),
18153
+ };
18154
+ }
18155
+ function readPause(row) {
18156
+ const text = (key) => row[key] === null || row[key] === undefined ? null : String(row[key]);
18157
+ return {
18158
+ id: Number(row["id"]),
18159
+ run: Number(row["run"]),
18160
+ taskRef: Number(row["task_ref"]),
18161
+ requestId: String(row["request_id"]),
18162
+ requestedBy: String(row["requested_by"]),
18163
+ requestedVia: String(row["requested_via"]),
18164
+ requestedAt: String(row["requested_at"]),
18165
+ note: row["note"] === null || row["note"] === undefined ? null : Number(row["note"]),
18166
+ sentAt: text("sent_at"),
18167
+ pausedAt: text("paused_at"),
18168
+ resumedAt: text("resumed_at"),
18169
+ endedAt: text("ended_at"),
17744
18170
  };
17745
18171
  }
17746
18172
  function readDecision(row) {