toolroll 0.9.96 → 0.9.98

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 (131) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +32 -32
  6. package/dist/browser-workspace.d.ts +6 -3
  7. package/dist/chat-actions.d.ts +40 -5
  8. package/dist/chat-actions.js +124 -13
  9. package/dist/chat-card.js +4 -1
  10. package/dist/chat-channel.d.ts +3 -0
  11. package/dist/chat-channel.js +24 -7
  12. package/dist/chat-decide.js +4 -2
  13. package/dist/chat-delivery.d.ts +5 -1
  14. package/dist/chat-delivery.js +74 -25
  15. package/dist/chat-follow.d.ts +6 -3
  16. package/dist/chat-follow.js +23 -7
  17. package/dist/chat-live-schema.d.ts +17 -0
  18. package/dist/chat-live-schema.js +32 -0
  19. package/dist/chat-live.d.ts +161 -0
  20. package/dist/chat-live.js +491 -0
  21. package/dist/chat-task-actions.js +1 -1
  22. package/dist/chat-terms.d.ts +8 -0
  23. package/dist/chat-terms.js +14 -0
  24. package/dist/chat-voice.js +1 -1
  25. package/dist/contracts/chat-actions.d.ts +6 -0
  26. package/dist/contracts/chat-actions.js +2 -0
  27. package/dist/contracts/cli.d.ts +6 -0
  28. package/dist/contracts/cli.js +3 -0
  29. package/dist/contracts/console-api.d.ts +1 -0
  30. package/dist/contracts/console-api.js +1 -0
  31. package/dist/contracts/lead-tools.d.ts +17 -0
  32. package/dist/contracts/lead-tools.js +4 -1
  33. package/dist/contracts/store-json.d.ts +1 -0
  34. package/dist/discord-api.d.ts +3 -1
  35. package/dist/discord-api.js +9 -3
  36. package/dist/discord-chat.d.ts +13 -3
  37. package/dist/discord-chat.js +77 -21
  38. package/dist/discord.js +8 -1
  39. package/dist/fast-start.d.ts +83 -6
  40. package/dist/fast-start.js +112 -15
  41. package/dist/home-groups.d.ts +39 -0
  42. package/dist/home-groups.js +33 -0
  43. package/dist/lead-answer-eval.d.ts +93 -0
  44. package/dist/lead-answer-eval.js +259 -0
  45. package/dist/lead-capabilities.js +1 -1
  46. package/dist/lead-cli-live.d.ts +56 -0
  47. package/dist/lead-cli-live.js +200 -0
  48. package/dist/lead-cli.d.ts +3 -0
  49. package/dist/lead-cli.js +34 -4
  50. package/dist/lead-context.js +2 -2
  51. package/dist/lead-contract.d.ts +1 -1
  52. package/dist/lead-contract.js +10 -7
  53. package/dist/lead-doors.d.ts +32 -0
  54. package/dist/lead-doors.js +80 -14
  55. package/dist/lead-latency-schema.d.ts +24 -0
  56. package/dist/lead-latency-schema.js +36 -0
  57. package/dist/lead-progress.d.ts +10 -1
  58. package/dist/lead-progress.js +1 -1
  59. package/dist/lead-queue-runtime.d.ts +1 -1
  60. package/dist/lead-queue-runtime.js +1 -1
  61. package/dist/lead-route.d.ts +56 -0
  62. package/dist/lead-route.js +104 -0
  63. package/dist/lead-status.d.ts +3 -0
  64. package/dist/lead-status.js +8 -3
  65. package/dist/lead-tools.d.ts +20 -0
  66. package/dist/lead-tools.js +66 -3
  67. package/dist/lead-voice.js +1 -1
  68. package/dist/lead.d.ts +4 -2
  69. package/dist/lead.js +97 -28
  70. package/dist/live-reply.d.ts +83 -0
  71. package/dist/live-reply.js +302 -0
  72. package/dist/local-owner.js +2 -0
  73. package/dist/one-step-chat-schema.d.ts +22 -0
  74. package/dist/one-step-chat-schema.js +37 -0
  75. package/dist/operate.d.ts +27 -1
  76. package/dist/operate.js +245 -33
  77. package/dist/project-delete.js +9 -0
  78. package/dist/project-knowledge.d.ts +10 -10
  79. package/dist/publish.d.ts +2 -0
  80. package/dist/publish.js +1 -1
  81. package/dist/pull-request-flow.d.ts +4 -0
  82. package/dist/pull-request-flow.js +16 -1
  83. package/dist/reply-shape.d.ts +9 -0
  84. package/dist/reply-shape.js +13 -0
  85. package/dist/reply-voice.d.ts +23 -0
  86. package/dist/reply-voice.js +37 -0
  87. package/dist/scope.d.ts +6 -1
  88. package/dist/scope.js +3 -2
  89. package/dist/serve.js +11 -4
  90. package/dist/server/chat.d.ts +2 -1
  91. package/dist/server/chat.js +22 -10
  92. package/dist/server/chrome.js +2 -2
  93. package/dist/server/home.d.ts +4 -3
  94. package/dist/server/home.js +33 -28
  95. package/dist/server/live.js +18 -21
  96. package/dist/server/render-chat.d.ts +5 -0
  97. package/dist/server/render-chat.js +15 -8
  98. package/dist/server/render-pages.js +2 -2
  99. package/dist/server/render-settings.d.ts +8 -0
  100. package/dist/server/render-settings.js +13 -2
  101. package/dist/server/route-table.js +2 -0
  102. package/dist/server/settings.js +14 -1
  103. package/dist/setup-guide.d.ts +2 -2
  104. package/dist/slack-api.js +11 -0
  105. package/dist/slack-chat.d.ts +11 -1
  106. package/dist/slack-chat.js +50 -5
  107. package/dist/slack-settings.js +8 -1
  108. package/dist/slack.js +10 -3
  109. package/dist/store.d.ts +33 -2
  110. package/dist/store.js +92 -16
  111. package/dist/subscription-chat.d.ts +2 -0
  112. package/dist/subscription-chat.js +3 -1
  113. package/dist/teams-chat.d.ts +9 -2
  114. package/dist/teams-chat.js +50 -2
  115. package/dist/teams.js +5 -2
  116. package/dist/telegram-lead.d.ts +3 -0
  117. package/dist/telegram-lead.js +132 -14
  118. package/dist/telegram-live.d.ts +116 -0
  119. package/dist/telegram-live.js +224 -0
  120. package/dist/telegram-status.js +9 -4
  121. package/dist/telegram.d.ts +12 -0
  122. package/dist/telegram.js +142 -10
  123. package/dist/toolroll-update.js +6 -1
  124. package/dist/tui-contract.d.ts +2 -0
  125. package/dist/tui-source.js +3 -0
  126. package/dist/tui.js +41 -41
  127. package/dist/typed-start.d.ts +51 -0
  128. package/dist/typed-start.js +98 -0
  129. package/dist/wake-hub.d.ts +72 -0
  130. package/dist/wake-hub.js +151 -0
  131. package/package.json +1 -1
package/dist/operate.js CHANGED
@@ -9,6 +9,7 @@ import { adapterPolicy } from "./server/route-policy.js";
9
9
  import { UNSENT_REPLY_MS } from "./telegram-settings.js";
10
10
  import { checkPublicCommand } from "./public-check.js";
11
11
  import { leadNameOf } from "./lead-identity.js";
12
+ import { modelName, quickLeadModel } from "./lead-route.js";
12
13
  import { maybeTriggerRepair } from "./dispose.js";
13
14
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
14
15
  import { fileAddTestsTask, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
@@ -122,7 +123,7 @@ import { homedir, hostname } from "node:os";
122
123
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
123
124
  import { releaseStalledUpdate, waitingUpdate } from "./toolroll-update.js";
124
125
  import { dur } from "./design/kit.js";
125
- import { checkoutIdle, setCheckoutIdle, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
126
+ import { checkoutIdle, setCheckoutIdle, leadQuickAnswersOn, setLeadQuickAnswers, openStore, openStoreNoMigrate, initLocalMode, enrollOwnerPassword, installationCreatedBy, laptopPortOf, recordLaptopPort, localModeOf, localOwnerBinding, archiveTaskFamily, restoreTaskFamily, shipChecksForTask, elapsedSeconds, CONCURRENT_WRITER_WAIT_MS, isDatabaseBusy, databasePath, BUILT_IN, parseCapabilityKey, verifiedAuthor, isDigestTime, predecessorWatches, RESULT_SCREENSHOTS, lifecycleOf, reportLifecycle, beatLifecycle, } from "./store.js";
126
127
  import { createHash, randomBytes, randomInt, randomUUID } from "node:crypto";
127
128
  import { claimInstance, clearInstance, openInstanceSocket } from "./console-instance.js";
128
129
  import { backgroundOff, backgroundOn, backgroundShow, laptopStatus, laptopStop, restartOwnBuilder } from "./laptop-background.js";
@@ -208,6 +209,7 @@ import { scanRepo } from "./capscan.js";
208
209
  import { computeGaps, describeCapability } from "./gaps.js";
209
210
  import { ask as promptAsk, askHidden as promptHidden, confirm as promptConfirm, interactive as promptInteractive, underAgent } from "./prompt.js";
210
211
  import { runLeadCli, answerContextLines } from "./lead-cli.js";
212
+ import { liveTerminalWanted, processTerminal } from "./lead-cli-live.js";
211
213
  import { confirmCoordinatorProposal, dismissCoordinatorProposal } from "./lead-doors.js";
212
214
  import { verifyApproverByPassword as approverByPassword } from "./principal.js";
213
215
  import { activeRemote, REMOTE_MESSAGES, REMOTE_REFUSED_FLAGS, REMOTE_SCOPES, RemoteRefusal, remoteAllows, remoteLensOf, remoteProjectOf, remoteRowOf, remoteSecret, reproveRemote, withRemote } from "./operate-remote.js";
@@ -279,6 +281,7 @@ import { noticeGitIncidents } from "./git-incident.js";
279
281
  import { containmentNotice, containmentStatus, currentContainment, describeContainment, resolveContainment } from "./containment.js";
280
282
  import { readPulls } from "./pulls.js";
281
283
  import { startMaintenance } from "./maintenance.js";
284
+ import { WakePoker, wakeHubFor } from "./wake-hub.js";
282
285
  import { livePin, modelWatchPass } from "./model-catalog.js";
283
286
  import { runModelsCommand } from "./models-cli.js";
284
287
  import { ALL_PROJECTS, chatApprovalSettings, chatApprovalWords, setChatApproval } from "./chat-approval.js";
@@ -598,6 +601,9 @@ Agents — which provider and model each phase runs on
598
601
  [--model <m>] [--weekly-usd <n>] [--daily-turns <n>] --as <you> --token <t>
599
602
  membership providers reuse a logged-in local harness with no dollar
600
603
  maximum; direct API providers require a key and weekly dollar ceiling
604
+ toolroll config set|clear|show quick-answers --as <you> --token <t>
605
+ quick answers on (set) or off (clear): plain status questions go to a
606
+ faster model on the Claude membership; changes stay on the main model
601
607
  toolroll chat --as <you> [--repo <path>…] [--say "…"] [--end]
602
608
  [--ceiling-usd <n>] [--json] (password at the prompt;
603
609
  --token <t> only for scripts — it lands in shell history)
@@ -5101,6 +5107,9 @@ async function configCommand(positional, flags, context) {
5101
5107
  return fail(write, json, "config", "usage", `unknown \`config ${action}\` — try ${CONFIG_ACTIONS.join(", ")}`, EXIT.usage);
5102
5108
  const repoGiven = text(flags, "repo");
5103
5109
  const scope = repoGiven === undefined ? INSTALLATION_SCOPE : canonicalProject(repoGiven) ?? resolve(repoGiven);
5110
+ // Settings → Lead's "Quick answers use a faster model", the same switch (responsive-chat.md §8).
5111
+ if (phase === "quick-answers")
5112
+ return quickAnswersConfig(action ?? "show", repoGiven !== undefined, flags, context);
5104
5113
  if (action === undefined || action === "show") {
5105
5114
  const installation = store.listPhaseConfig(INSTALLATION_SCOPE);
5106
5115
  const project = scope === INSTALLATION_SCOPE ? [] : store.listPhaseConfig(scope);
@@ -6419,8 +6428,22 @@ async function runWatchLoop(args) {
6419
6428
  // an authority this process no longer holds.
6420
6429
  let leaseLost = false;
6421
6430
  const followController = new AbortController();
6431
+ // The process's one wake poller (wake-hub.ts) replaces every per-loop and per-lane 50 ms read of the wake counter.
6432
+ // It only says "look": every claim still re-proves its fences.
6433
+ const hub = wakeHubFor(store);
6434
+ const wakes = new WakePoker();
6422
6435
  args.onFollowController?.(followController);
6423
- const stopping = () => args.isStopping() || leaseLost;
6436
+ // The loop or a build lane failed (the store could not be read): everything stops, cleans up, and then the first
6437
+ // failure is thrown. Without this a failed lane skipped the cleanup, and a failed loop left its lanes running.
6438
+ let failed = false;
6439
+ let failure;
6440
+ const fail = (error) => {
6441
+ if (!failed)
6442
+ failure = error;
6443
+ failed = true;
6444
+ wakes.poke();
6445
+ };
6446
+ const stopping = () => args.isStopping() || leaseLost || failed;
6424
6447
  const heartbeat = setInterval(() => {
6425
6448
  let renewed = false;
6426
6449
  let problem = "the lease or credential was taken";
@@ -6433,6 +6456,8 @@ async function runWatchLoop(args) {
6433
6456
  if (!renewed && !leaseLost) {
6434
6457
  leaseLost = true;
6435
6458
  followController.abort();
6459
+ // A lost lease ends every doze of this loop and its lanes now, not at the next beat (live-wakeups.md).
6460
+ wakes.poke();
6436
6461
  progress(`watch: ${runner} on ${repo}: ${problem} — stopping without admitting more work`);
6437
6462
  }
6438
6463
  }, WATCH_HEARTBEAT_MS);
@@ -6487,12 +6512,9 @@ async function runWatchLoop(args) {
6487
6512
  const deadline = runFor === null ? null : startedAt + runFor;
6488
6513
  let lastTick = 0;
6489
6514
  let lastBridge = 0;
6490
- let lastPush = 0;
6491
- let lastWebhook = 0;
6492
6515
  let ticks = 0;
6493
6516
  let built = 0;
6494
6517
  let brokeCount = 0;
6495
- const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
6496
6518
  // Reconciliation has its own cadence and sink. Sharing tick's sink would
6497
6519
  // interleave envelopes while a build awaits its provider or project check.
6498
6520
  const sweepOwner = {};
@@ -6517,8 +6539,12 @@ async function runWatchLoop(args) {
6517
6539
  throw new Error(maintenanceLines.join(" "));
6518
6540
  if (reconciliationFailure !== null)
6519
6541
  progress("watch: reconciliation recovered — new work may resume");
6542
+ const recovered = !reconciliationReady;
6520
6543
  reconciliationFailure = null;
6521
6544
  reconciliationReady = true;
6545
+ // Admission resumes now: a loop dozing while paused looks again at once.
6546
+ if (recovered)
6547
+ wakes.poke();
6522
6548
  if (!stopping() && store.openedPublications().length > 0) {
6523
6549
  try {
6524
6550
  const checks = await observeChecks(store, {
@@ -6646,6 +6672,9 @@ async function runWatchLoop(args) {
6646
6672
  run: async () => { if (await modelWatchPass(store, context.clock()))
6647
6673
  progress("watch: checked models and CLI versions"); },
6648
6674
  });
6675
+ // The process's delivery and fast CI beats (live-wakeups.md): one set however many project loops run.
6676
+ // The fast CI pass runs only while this loop would admit work: not stopping, reconciled, and no update drain.
6677
+ const leaveServiceBeats = joinServiceBeats({ context, progress, ciReady: () => !admissionStopped() });
6649
6678
  // Parallel builds (project-concurrency.ts): this loop is the project's
6650
6679
  // first build lane and keeps every housekeeping duty; extra lanes, up to
6651
6680
  // the project's own number (never past the worker's capacity), only
@@ -6721,16 +6750,16 @@ async function runWatchLoop(args) {
6721
6750
  const seqIdle = store.wakeSeq();
6722
6751
  const dozeUntil = Math.min(Date.now() + tickEveryMs, deadline ?? Number.MAX_SAFE_INTEGER);
6723
6752
  const theirTurnEnded = () => yieldingTo !== null && yieldingSince !== null && passes.lastPass(yieldingTo) > yieldingSince;
6724
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
6725
- await sleep(paused() ? 250 : 50);
6726
- }
6753
+ // Doze on the shared poller: a wake, the cadence, a stop, a lost lease or the fair-share turn ending.
6754
+ await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
6727
6755
  }
6728
6756
  };
6729
6757
  const ensureLanes = () => {
6730
6758
  const facts = slotFacts(null);
6731
6759
  const wanted = effectiveConcurrency(facts.limitOf(repo), facts.capacity);
6760
+ // A lane never rejects: its failure is kept, every doze is woken, and the loop's own cleanup runs before it is thrown.
6732
6761
  while (lanes.length + 1 < wanted && !stopping())
6733
- lanes.push(buildLane(lanes.length + 1));
6762
+ lanes.push(buildLane(lanes.length + 1).catch(fail));
6734
6763
  };
6735
6764
  let firstLaneYieldingSince = null;
6736
6765
  let firstLaneYieldingTo = null;
@@ -6739,7 +6768,9 @@ async function runWatchLoop(args) {
6739
6768
  await maintenance.runNow();
6740
6769
  while (!stopping() && (deadline === null || Date.now() < deadline)) {
6741
6770
  if (paused()) {
6742
- await sleep(250);
6771
+ // Paused: look again when the wake moves (pause and resume bump it), when reconciliation recovers (a poke),
6772
+ // or after a second at most, whichever comes first.
6773
+ await hub.changed({ baseline: store.wakeSeq(), deadline: Date.now() + 1_000, until: stopping, poker: wakes });
6743
6774
  continue;
6744
6775
  }
6745
6776
  ensureLanes();
@@ -6809,25 +6840,8 @@ async function runWatchLoop(args) {
6809
6840
  (published.failed > 0 ? `, gave up on ${published.failed}` : ""));
6810
6841
  }
6811
6842
  }
6812
- // Push rides its OWN cadence, whether or not Telegram holds the wire
6813
- // (arc 3 finding 8): the pair ledger's claims fence concurrent loops.
6814
- if (now - lastPush >= 45_000) {
6815
- lastPush = now;
6816
- try {
6817
- await pushPass(context.store, { configDir: dirname(context.databaseFile), clock: () => new Date() });
6818
- }
6819
- catch {
6820
- // Push is additive; a broken pass never stops the watch.
6821
- }
6822
- }
6823
- if (now - lastWebhook >= bridgeEveryMs) {
6824
- lastWebhook = now;
6825
- const dir = dirname(context.databaseFile);
6826
- const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
6827
- const targets = activeWebhookTargets(process.env, dir, primary.channel);
6828
- if (targets.length > 0)
6829
- await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
6830
- }
6843
+ // Web push and outgoing webhooks ride the service's own delivery beat (joinServiceBeats, live-wakeups.md):
6844
+ // every 5 s, and at once when the wake moves with new rows, never behind a build on this path.
6831
6845
  // The embedded follower owns the wire while it lives; the timer-driven
6832
6846
  // pass is the fallback shape for a watch started before a token existed.
6833
6847
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
@@ -6856,7 +6870,7 @@ async function runWatchLoop(args) {
6856
6870
  // due, a signal lands, or the trial window (--for) ends. Reading one
6857
6871
  // integer from SQLite twice a second is the entire idle cost — no
6858
6872
  // token has anywhere to be spent from here.
6859
- const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, lastWebhook + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
6873
+ const idleUntil = Math.min(lastTick + tickEveryMs, lastBridge + bridgeEveryMs, deadline ?? Number.MAX_SAFE_INTEGER) - Date.now();
6860
6874
  // Doze for the WHOLE idle window, waking early only for a signal or a
6861
6875
  // sequence move — not for one 500ms step and then another pass
6862
6876
  // (2026-09-05: that read as two ticks a second, all night, with
@@ -6864,11 +6878,13 @@ async function runWatchLoop(args) {
6864
6878
  const seqIdle = store.wakeSeq();
6865
6879
  const dozeUntil = Date.now() + Math.max(idleUntil, 0);
6866
6880
  const theirTurnEnded = () => firstLaneYieldingTo !== null && firstLaneYieldingSince !== null && passes.lastPass(firstLaneYieldingTo) > firstLaneYieldingSince;
6867
- while (!stopping() && Date.now() < dozeUntil && store.wakeSeq() === seqIdle && !theirTurnEnded()) {
6868
- await sleep(50);
6869
- }
6881
+ await hub.changed({ baseline: seqIdle, deadline: dozeUntil, until: () => stopping() || theirTurnEnded(), poker: wakes });
6870
6882
  }
6871
6883
  }
6884
+ catch (error) {
6885
+ // Kept, not thrown yet: the lanes must see the stop, and the cleanup below must run first.
6886
+ fail(error);
6887
+ }
6872
6888
  finally {
6873
6889
  // Every build lane finishes or hands its attempt back before the
6874
6890
  // loop's lease and episode end.
@@ -6879,6 +6895,7 @@ async function runWatchLoop(args) {
6879
6895
  await flowMoves.stop();
6880
6896
  await flowSteps.stop();
6881
6897
  await modelWatch.stop();
6898
+ await leaveServiceBeats();
6882
6899
  clearInterval(heartbeat);
6883
6900
  followController.abort();
6884
6901
  if (follower !== null)
@@ -6889,6 +6906,8 @@ async function runWatchLoop(args) {
6889
6906
  store.endWatchEpisode(incarnation, { ticks, built, broke: brokeCount }, new Date());
6890
6907
  store.releaseWatchLease(runner, repo, incarnation, new Date());
6891
6908
  }
6909
+ if (failed)
6910
+ throw failure;
6892
6911
  if (leaseLost) {
6893
6912
  return { ok: false, reason: "lease-lost", detail: `the watch lease for ${runner} on ${repo} stopped renewing — another process may have taken this worker over`, ticks, built, broke: brokeCount };
6894
6913
  }
@@ -6958,6 +6977,7 @@ async function watchCommand(flags, context) {
6958
6977
  }
6959
6978
  stopping = true;
6960
6979
  followAbort?.abort();
6980
+ wakeHubFor(context.store).poke();
6961
6981
  graceTimer = setTimeout(hardStop, stopGraceMs);
6962
6982
  };
6963
6983
  process.on("SIGINT", stop);
@@ -10671,6 +10691,8 @@ async function chatCommand(flags, context) {
10671
10691
  ...(context.evidenceRoot === undefined ? {} : { evidenceRoot: context.evidenceRoot }),
10672
10692
  // The console's own address (and its public one): links there read as "the task", "the result", "Settings → Lead".
10673
10693
  appOrigin: [loadConsoleUrl(process.env, dirname(context.databaseFile)), phoneOrigin(process.env, dirname(context.databaseFile))],
10694
+ // A live status line and the answer as it is written, on a real terminal only (tests drive runLeadCli directly).
10695
+ ...(context.leadSeams === undefined && liveTerminalWanted(json, process.env, process.stdout.isTTY === true, () => underAgent(process.env)) ? { liveTerminal: processTerminal() } : {}),
10674
10696
  });
10675
10697
  return result.code;
10676
10698
  }
@@ -12618,6 +12640,8 @@ async function runUpWorkerRole(context, config, port, build) {
12618
12640
  resolveStopped();
12619
12641
  for (const controller of followControllers)
12620
12642
  controller.abort();
12643
+ // Every project loop's doze ends now (wake-hub.ts), not at its next beat.
12644
+ wakeHubFor(store).poke();
12621
12645
  const building = store.liveClaimCount(runnerName, clock());
12622
12646
  if (building > 0) {
12623
12647
  progress(`Stopping: ${building} build(s) running. Waiting up to ${Math.round(config.stopGraceMs / 1000)}s for them to finish; any still running then is saved and resumes when the builder starts again.`);
@@ -14087,3 +14111,191 @@ async function projectFallbackCommand(positional, flags, context) {
14087
14111
  return fail(context.write, context.json, command, "refused", done.problem, EXIT.refused);
14088
14112
  return succeed(context.write, context.json, command, shown(), () => [...words(), "Tasks scoped from now on use this; tasks already approved keep the chain they were approved with."]);
14089
14113
  }
14114
+ /** `toolroll config set|clear|show quick-answers`: Settings → Lead's quick-answer switch (responsive-chat.md §8). On
14115
+ * or off is installation-wide, so changing it takes an instance operator's password, as the web page's route does. */
14116
+ async function quickAnswersConfig(action, repoGiven, flags, context) {
14117
+ const { store, write, json, clock } = context;
14118
+ const command = `config ${action}`;
14119
+ if (repoGiven)
14120
+ return fail(write, json, command, "usage", "quick answers are installation-wide: no --repo", EXIT.usage);
14121
+ const said = () => {
14122
+ const on = leadQuickAnswersOn(store);
14123
+ const model = quickLeadModel(store);
14124
+ return { payload: { quickAnswers: { on, model } }, lines: () => [on
14125
+ ? `Quick answers are on: plain status questions are answered by ${modelName(model)}; anything that asks for a change stays on the lead's main model.`
14126
+ : "Quick answers are off: the lead's main model answers everything."] };
14127
+ };
14128
+ if (action === "show") {
14129
+ const shown = said();
14130
+ return succeed(write, json, command, shown.payload, shown.lines);
14131
+ }
14132
+ const acting = await askCredentials(flags, context);
14133
+ if (acting === null)
14134
+ return fail(write, json, command, "usage", "changing quick answers takes `--as <you> --token <t>`", EXIT.usage);
14135
+ const verified = authenticateApprover(store, acting.name, acting.token);
14136
+ if (!verified.ok || !store.isInstanceOperator(acting.name))
14137
+ return fail(write, json, command, "unauthenticated", "an instance operator changes quick answers, with their password", EXIT.refused);
14138
+ setLeadQuickAnswers(store, action === "set", acting.name, clock());
14139
+ const shown = said();
14140
+ return succeed(write, json, command, shown.payload, shown.lines);
14141
+ }
14142
+ /** The delivery beat: web push and outgoing webhooks, off the build path. */
14143
+ export const DELIVERY_EVERY_MS = 5_000;
14144
+ /** The fast CI beat: only while an opened pull request waits on its checks or its merge. */
14145
+ export const FAST_CI_EVERY_MS = 30_000;
14146
+ /** The most pull requests one fast CI pass reads. */
14147
+ export const FAST_CI_LIMIT = 10;
14148
+ /** A pull request whose checks read as "none" stays on the fast pass this long after it was last updated: CI that
14149
+ * has not registered yet, not a project without CI, which the 5 minute pass keeps reading. */
14150
+ const FAST_CI_NONE_WINDOW_MS = 30 * 60_000;
14151
+ const serviceBeats = new WeakMap();
14152
+ /** The newest notification id: the delivery beat's high-water mark. */
14153
+ function newestNotificationId(store) {
14154
+ return Number(store.handle.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM notification").get()?.["id"] ?? 0);
14155
+ }
14156
+ /**
14157
+ * The opened pull requests the fast CI pass reads, oldest read or tried (`attempted`) first, at most `limit`: checks not settled (never read,
14158
+ * running, or "none" shortly after an update), or green with its merge still waiting (a merge intent in flight, or a
14159
+ * followed pull request not yet marked ready on its accepted commit). Failing and settled ones stay on the 5 minute pass.
14160
+ */
14161
+ export function fastCiWaiting(store, now, limit = FAST_CI_LIMIT, attempted = new Map()) {
14162
+ const waiting = store.openedPublications().filter(publication => {
14163
+ if (publication.prNumber === null)
14164
+ return false;
14165
+ const state = publication.lastCheckState;
14166
+ if (state === null || state === "running")
14167
+ return true;
14168
+ if (state === "none")
14169
+ return now.getTime() - Date.parse(publication.lastCheckAt ?? publication.updatedAt) < FAST_CI_NONE_WINDOW_MS
14170
+ && now.getTime() - Date.parse(publication.updatedAt) < FAST_CI_NONE_WINDOW_MS;
14171
+ if (state !== "passing")
14172
+ return false;
14173
+ const intent = store.mergeIntentFor(publication.id);
14174
+ if (intent !== null && ["pending", "claimed", "waiting-human", "firing"].includes(intent.state))
14175
+ return true;
14176
+ const follow = store.handle.prepare("SELECT flow_card, ready_head, merge_commit FROM pull_request_follow WHERE publication = ?").get(publication.id);
14177
+ return follow !== undefined && follow["flow_card"] == null && follow["merge_commit"] == null && follow["ready_head"] !== publication.headSha;
14178
+ });
14179
+ // Oldest attempt first: a pull request GitHub cannot read still takes its turn, then goes to the back of the line.
14180
+ const last = (one) => {
14181
+ const read = one.lastCheckAt ?? "", tried = attempted.get(one.id) ?? "";
14182
+ return tried > read ? tried : read;
14183
+ };
14184
+ return waiting
14185
+ .sort((a, b) => last(a).localeCompare(last(b)) || a.id - b.id)
14186
+ .slice(0, limit);
14187
+ }
14188
+ /**
14189
+ * Join this process's service beats: one delivery beat (web push and outgoing webhooks every 5 s, and at once when
14190
+ * the wake moves with new notifications) and one fast CI beat (every 30 s, the existing `observeChecks` reader, only
14191
+ * for pull requests that wait). One set per store, however many project loops join; the last to leave stops them.
14192
+ * Opening a pull request stays on the tick path, and the 5 minute pass alone may set reconciliation ready.
14193
+ */
14194
+ export function joinServiceBeats(member) {
14195
+ const store = member.context.store;
14196
+ let beats = serviceBeats.get(store);
14197
+ if (beats === undefined)
14198
+ beats = startServiceBeats(store);
14199
+ beats.members.add(member);
14200
+ const joined = beats;
14201
+ return async () => {
14202
+ if (!joined.members.delete(member) || joined.members.size > 0)
14203
+ return;
14204
+ joined.stopped = true;
14205
+ if (serviceBeats.get(store) === joined)
14206
+ serviceBeats.delete(store);
14207
+ joined.poker.poke();
14208
+ await joined.delivery.stop();
14209
+ await joined.fastCi.stop();
14210
+ await joined.wakeLoop;
14211
+ };
14212
+ }
14213
+ function startServiceBeats(store) {
14214
+ const anyMember = () => beats.members.values().next().value;
14215
+ let deliveredThrough = newestNotificationId(store);
14216
+ let deliveryProblem = null;
14217
+ // When the fast CI pass last tried each pull request, read or not.
14218
+ const attempted = new Map();
14219
+ const delivery = startMaintenance({
14220
+ intervalMs: DELIVERY_EVERY_MS,
14221
+ shouldStop: () => beats.stopped,
14222
+ onError: error => {
14223
+ const detail = describe(error);
14224
+ if (detail !== deliveryProblem)
14225
+ anyMember()?.progress(`watch: outgoing webhooks failed — ${detail}; they try again in a few seconds`);
14226
+ deliveryProblem = detail;
14227
+ },
14228
+ run: async () => {
14229
+ const member = anyMember();
14230
+ if (member === undefined)
14231
+ return;
14232
+ const { context } = member;
14233
+ const dir = dirname(context.databaseFile);
14234
+ deliveredThrough = newestNotificationId(store);
14235
+ // Push is additive; a broken pass never stops the watch. The pair ledger's claims fence any other sender.
14236
+ try {
14237
+ await pushPass(store, { configDir: dir, clock: () => new Date() });
14238
+ }
14239
+ catch { /* tried again next beat */ }
14240
+ const primary = effectivePrimary(process.env, dir, loadBotToken(process.env, context.telegramTokenFile) !== null);
14241
+ const targets = activeWebhookTargets(process.env, dir, primary.channel);
14242
+ if (targets.length > 0)
14243
+ await webhookPass(store, { targets, consoleUrl: loadConsoleUrl(process.env, dir), clock: context.clock });
14244
+ deliveryProblem = null;
14245
+ },
14246
+ });
14247
+ const fastCi = startMaintenance({
14248
+ intervalMs: FAST_CI_EVERY_MS,
14249
+ shouldStop: () => beats.stopped,
14250
+ // A failed fast pass is skipped: it records nothing and says nothing. The 5 minute pass still reads every PR.
14251
+ onError: () => { },
14252
+ run: async () => {
14253
+ // Every project loop must still stand, read again before each revision, ready mark and merge: a stop, a lost
14254
+ // lease, a failed reconciliation or an update drain that arrives while GitHub answers ends the pass there.
14255
+ const standing = () => !beats.stopped && beats.members.size > 0 && [...beats.members].every(one => one.ciReady());
14256
+ if (!standing())
14257
+ return;
14258
+ const { context, progress } = beats.members.values().next().value;
14259
+ const now = context.clock();
14260
+ const waiting = fastCiWaiting(store, now, FAST_CI_LIMIT, attempted);
14261
+ if (waiting.length === 0)
14262
+ return;
14263
+ // A pull request GitHub would not read keeps its place in line only until the others had their turn.
14264
+ if (attempted.size > 500)
14265
+ attempted.clear();
14266
+ for (const one of waiting)
14267
+ attempted.set(one.id, now.toISOString());
14268
+ const exec = context.publishExec === undefined ? {} : { exec: context.publishExec };
14269
+ const checks = await observeChecks(store, { ...exec, publications: waiting });
14270
+ if (!standing())
14271
+ return;
14272
+ if (checks.failing > 0)
14273
+ progress(`watch: CI is red on ${checks.failing} published PR(s) — the outbox has it`);
14274
+ const followed = await followPullRequests(store, checks.seen, { evidenceRoot: context.evidenceRoot, ...exec, proceed: standing });
14275
+ if (followed.revisions > 0)
14276
+ progress(`watch: filed ${followed.revisions} CI revision(s)`);
14277
+ if (followed.merged > 0)
14278
+ progress(`watch: merged ${followed.merged} pull request(s) whose checks passed`);
14279
+ },
14280
+ });
14281
+ const poker = new WakePoker();
14282
+ const hub = wakeHubFor(store);
14283
+ const beats = { members: new Set(), stopped: false, delivery, fastCi, poker, wakeLoop: Promise.resolve() };
14284
+ // A wake with new notifications sends them now, not at the next beat.
14285
+ beats.wakeLoop = (async () => {
14286
+ while (!beats.stopped) {
14287
+ try {
14288
+ const why = await hub.changed({ baseline: store.wakeSeq(), deadline: Number.MAX_SAFE_INTEGER, until: () => beats.stopped, poker });
14289
+ if (why === "wake" && !beats.stopped && newestNotificationId(store) > deliveredThrough)
14290
+ void delivery.runNow();
14291
+ }
14292
+ catch {
14293
+ // The store could not be read: the 5 s beat still runs; look again shortly.
14294
+ await new Promise(resolveLater => { const timer = setTimeout(resolveLater, 1_000); timer.unref?.(); });
14295
+ }
14296
+ }
14297
+ })();
14298
+ serviceBeats.set(store, beats);
14299
+ void delivery.runNow();
14300
+ return beats;
14301
+ }
@@ -239,6 +239,15 @@ function deleteRows(store, repo, d, now) {
239
239
  del("chat_decide_action", IN("task_id"), K);
240
240
  del("chat_decide_prompt", IN("task_id"), K);
241
241
  del("chat_message_ref", `${IN("decision")} OR ${IN("task_id")} OR ${IN("run")} OR project = ? OR ${IN("notification")} OR ${IN("task_ref")}`, decisions, K, R, repo, notifications, T);
242
+ // A typed yes to a Start card (one-step-chat.md C5). Its record goes with its message. Where the message stays (it
243
+ // named no task of this project) but the card's proposal goes, the record is settled instead and forgets the task: a
244
+ // yes still waiting is then answered by Toolroll as one that started nothing, never handed to the lead.
245
+ del("chat_typed_start", `provider || ':' || event IN (SELECT value FROM json_each(?))`, conversations);
246
+ if (tableExists(db, "chat_typed_start")) {
247
+ db.prepare(`UPDATE chat_typed_start SET outcome = 'not-counted', task_id = NULL, finished = COALESCE(finished, ?) WHERE ${IN("proposal")}`).run(now.toISOString(), proposals);
248
+ // A yes that started one of this project's tasks, said in a message that stays: the record forgets the task.
249
+ db.prepare(`UPDATE chat_typed_start SET task_id = NULL WHERE ${IN("task_id")}`).run(K);
250
+ }
242
251
  del("chat_part", `provider || ':' || id IN (${parts})`, conversations, K, R);
243
252
  del("chat_event", `provider || ':' || id IN (SELECT value FROM json_each(?))`, conversations);
244
253
  del("chat_card_task", IN("task_ref"), T);
@@ -22,18 +22,20 @@ export type KnowledgeView = ReturnType<typeof knowledgeView>;
22
22
  export declare function knowledgeVersion(store: Store, repo: string, actor: string, revision: number): Knowledge;
23
23
  /** Chat reads only an admitted project. Keep the full source in the project UI. */
24
24
  export declare function conversationKnowledge(store: Store, repo: string, actor: string, reference?: string, decision?: number): {
25
- references?: never;
25
+ content?: never;
26
+ history?: never;
26
27
  revision: number;
27
28
  stale: boolean;
28
29
  notice: string;
29
30
  title?: never;
30
- content?: never;
31
- history?: never;
32
31
  instructions?: never;
32
+ references?: never;
33
33
  decisions?: never;
34
34
  waiting?: never;
35
35
  } | {
36
- references?: never;
36
+ content?: never;
37
+ revision?: never;
38
+ history?: never;
37
39
  stale?: never;
38
40
  decision: {
39
41
  repo: string;
@@ -52,27 +54,25 @@ export declare function conversationKnowledge(store: Store, repo: string, actor:
52
54
  afterwards?: string;
53
55
  notice: string;
54
56
  title?: never;
55
- content?: never;
56
- revision?: never;
57
- history?: never;
58
57
  instructions?: never;
58
+ references?: never;
59
59
  decisions?: never;
60
60
  waiting?: never;
61
61
  } | {
62
- references?: never;
62
+ history?: never;
63
63
  stale?: never;
64
64
  revision: number;
65
65
  title: string;
66
66
  content: string;
67
67
  notice: string;
68
- history?: never;
69
68
  instructions?: never;
69
+ references?: never;
70
70
  decisions?: never;
71
71
  waiting?: never;
72
72
  } | {
73
+ content?: never;
73
74
  stale?: never;
74
75
  title?: never;
75
- content?: never;
76
76
  revision: number;
77
77
  history: {
78
78
  revision: number;
package/dist/publish.d.ts CHANGED
@@ -107,6 +107,8 @@ export type ObservedPullRequest = {
107
107
  export declare function observeChecks(store: Store, options: {
108
108
  clock?: () => Date;
109
109
  exec?: PublishExec;
110
+ /** Read only these (the fast pass's waiting pull requests, live-wakeups.md); every one is read exactly as below. */
111
+ publications?: readonly Publication[];
110
112
  }): Promise<CheckReport>;
111
113
  export declare const MERGE_LEASE_MS: number;
112
114
  export declare const FIRING_GRACE_MS: number;
package/dist/publish.js CHANGED
@@ -304,7 +304,7 @@ export async function observeChecks(store, options) {
304
304
  const clock = options.clock ?? (() => new Date());
305
305
  const exec = options.exec ?? execRun;
306
306
  const report = { observed: 0, failing: 0, resolved: 0, problems: [], seen: [] };
307
- for (const publication of store.openedPublications()) {
307
+ for (const publication of options.publications ?? store.openedPublications()) {
308
308
  if (publication.prNumber === null)
309
309
  continue;
310
310
  // The observation generation is RESERVED before the network call
@@ -129,6 +129,8 @@ export declare function followPullRequests(store: Store, seen: readonly Observed
129
129
  evidenceRoot: string;
130
130
  exec?: PublishExec;
131
131
  clock?: () => Date;
132
+ /** Asked again before each revision, ready mark and merge: false ends the pass there and writes nothing more. */
133
+ proceed?: () => boolean;
132
134
  }): Promise<FollowReport>;
133
135
  /** A person's merge: their password first, then the same checked merge. */
134
136
  export declare function mergeAsPerson(store: Store, input: {
@@ -156,6 +158,8 @@ export declare function mergePullRequest(store: Store, input: {
156
158
  method?: MergeMethod;
157
159
  /** Only a person merging by hand may skip waiting for the full check. */
158
160
  anyway?: boolean;
161
+ /** An automatic pass's standing, read in the write that reserves the merge: false refuses ("halted") and merges nothing. */
162
+ proceed?: () => boolean;
159
163
  }): Promise<Result<{
160
164
  commit: string | null;
161
165
  }>>;
@@ -285,7 +285,10 @@ export async function followPullRequests(store, seen, options) {
285
285
  const exec = options.exec ?? execRun;
286
286
  const clock = options.clock ?? (() => new Date());
287
287
  const report = { revisions: 0, asked: 0, ready: 0, merged: 0, problems: [] };
288
+ const halted = () => options.proceed?.() === false;
288
289
  for (const one of seen) {
290
+ if (halted())
291
+ break;
289
292
  const publication = one.publication;
290
293
  const follow = followOf(store, publication.id);
291
294
  // A flow's Pull request zone follows its own PR: green moves the card on, red takes its failure path.
@@ -313,6 +316,8 @@ export async function followPullRequests(store, seen, options) {
313
316
  const check = failingChecks(one.rollup)[0] ?? { name: "a check", url: null };
314
317
  const skeleton = ciRevisionNote(check.name, pr, one.headOid, "");
315
318
  const excerpt = await logExcerpt(exec, publication.githubRepo, check.url, 490 - skeleton.length);
319
+ if (halted())
320
+ break;
316
321
  const note = ciRevisionNote(check.name, pr, one.headOid, excerpt);
317
322
  const request = createHash("sha256").update(`ci-revision ${publication.githubRepo} ${pr} ${one.headOid}`).digest("hex").slice(0, 32);
318
323
  const scope = store.getScope(taskId);
@@ -348,11 +353,17 @@ export async function followPullRequests(store, seen, options) {
348
353
  const grant = store.publicationGrantFor(store.refById(publication.taskRef)?.repo ?? "");
349
354
  updateFollow(store, publication.id, { ready_head: one.headOid }, clock());
350
355
  if (grant?.mergeWhenGreen === true && grant.publishOn === "complete") {
351
- const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock });
356
+ const merged = await mergePullRequest(store, { runId: publication.run, by: "merge when checks pass", exec, clock,
357
+ ...(options.proceed === undefined ? {} : { proceed: options.proceed }) });
352
358
  if (merged.ok) {
353
359
  report.merged++;
354
360
  continue;
355
361
  }
362
+ // Halted before the merge was reserved: the ready mark is taken back so the next pass merges it, and nobody is asked to.
363
+ if (merged.reason === "halted") {
364
+ updateFollow(store, publication.id, { ready_head: follow.readyHead }, clock());
365
+ break;
366
+ }
356
367
  report.problems.push(`PR #${pr}: ${merged.message}`);
357
368
  }
358
369
  store.enqueueNotification({
@@ -485,6 +496,8 @@ export async function mergePullRequest(store, input) {
485
496
  // One merge at a time, each owning its reservation by its token: a second caller is refused, and each releases only its own.
486
497
  const token = randomUUID();
487
498
  const reserved = store.transact(() => {
499
+ if (input.proceed?.() === false)
500
+ return "halted";
488
501
  if (refArchived(store, publication.taskRef))
489
502
  return "archived";
490
503
  if (store.handle.prepare("SELECT 1 FROM hold WHERE task_ref = ? AND owner_id LIKE 'project-delete:%' AND (until IS NULL OR until > ?)").get(publication.taskRef, clock().toISOString()) !== undefined)
@@ -494,6 +507,8 @@ export async function mergePullRequest(store, input) {
494
507
  store.handle.prepare("INSERT OR REPLACE INTO merge_in_flight (publication, task_ref, token, by, at) VALUES (?, ?, ?, ?, ?)").run(publication.id, publication.taskRef, token, input.by, clock().toISOString());
495
508
  return "ok";
496
509
  });
510
+ if (reserved === "halted")
511
+ return refuse("halted", "The worker is stopping or paused, so this merge waits for its next pass.");
497
512
  if (reserved === "archived")
498
513
  return refuse("archived", "This task is put away. Restore it before merging.");
499
514
  if (reserved === "deleting")
@@ -35,6 +35,15 @@ export declare function linkLabel(url: string, appOrigin?: ShapeOptions["appOrig
35
35
  * changes nothing.
36
36
  */
37
37
  export declare function shapeReply(text: string, options?: ShapeOptions): string;
38
+ /**
39
+ * A reply still being written, shaped with the final reply's presentation policy (docs/plans/responsive-chat.md §2.2),
40
+ * except that ids are dropped unless the owner asked for them: whether a reply names several of a kind isn't known
41
+ * until it ends, so a prefix never keeps them on that ground. Presentation, not redaction.
42
+ */
43
+ export declare function shapeReplyPrefix(text: string, options?: ShapeOptions): string;
44
+ /** The first window of a shaped text of at most `size` characters, cut where a part would be (never inside code, a link,
45
+ * a bold anchor or an emoji's two halves). A single link longer than the window stays whole, so the caller measures. */
46
+ export declare function shapedWindow(shaped: string, size: number): string;
38
47
  /**
39
48
  * A long reply split into parts of at most `size` characters, then each part shaped: the cut is made on the reply as
40
49
  * written, at a paragraph or line where possible and never inside code, a link or a bold anchor, so no part carries