toolroll 0.9.97 โ†’ 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 (78) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/approval-authority.d.ts +10 -1
  3. package/dist/approval-authority.js +18 -2
  4. package/dist/browser/workspace.js +1 -1
  5. package/dist/browser-workspace.d.ts +4 -3
  6. package/dist/chat-actions.d.ts +40 -5
  7. package/dist/chat-actions.js +124 -13
  8. package/dist/chat-card.js +4 -1
  9. package/dist/chat-channel.d.ts +3 -0
  10. package/dist/chat-channel.js +24 -7
  11. package/dist/chat-decide.js +4 -2
  12. package/dist/chat-task-actions.js +1 -1
  13. package/dist/chat-terms.d.ts +8 -0
  14. package/dist/chat-terms.js +14 -0
  15. package/dist/chat-voice.js +1 -1
  16. package/dist/contracts/chat-actions.d.ts +6 -0
  17. package/dist/contracts/chat-actions.js +2 -0
  18. package/dist/contracts/cli.d.ts +6 -0
  19. package/dist/contracts/cli.js +3 -0
  20. package/dist/contracts/lead-tools.d.ts +17 -0
  21. package/dist/contracts/lead-tools.js +4 -1
  22. package/dist/contracts/store-json.d.ts +1 -0
  23. package/dist/desktop-update.d.ts +1 -1
  24. package/dist/fast-start.d.ts +83 -6
  25. package/dist/fast-start.js +112 -15
  26. package/dist/home-groups.d.ts +39 -0
  27. package/dist/home-groups.js +33 -0
  28. package/dist/lead-answer-eval.d.ts +7 -0
  29. package/dist/lead-answer-eval.js +6 -2
  30. package/dist/lead-capabilities.js +1 -1
  31. package/dist/lead-context.js +2 -2
  32. package/dist/lead-contract.d.ts +1 -1
  33. package/dist/lead-contract.js +10 -7
  34. package/dist/lead-doors.d.ts +32 -0
  35. package/dist/lead-doors.js +80 -14
  36. package/dist/lead-status.d.ts +3 -0
  37. package/dist/lead-status.js +8 -3
  38. package/dist/lead-tools.d.ts +20 -0
  39. package/dist/lead-tools.js +66 -3
  40. package/dist/lead-voice.js +1 -1
  41. package/dist/lead.js +18 -2
  42. package/dist/local-owner.js +2 -0
  43. package/dist/one-step-chat-schema.d.ts +22 -0
  44. package/dist/one-step-chat-schema.js +37 -0
  45. package/dist/operate.d.ts +27 -1
  46. package/dist/operate.js +206 -32
  47. package/dist/project-delete.js +9 -0
  48. package/dist/project-knowledge.d.ts +7 -7
  49. package/dist/publish.d.ts +2 -0
  50. package/dist/publish.js +1 -1
  51. package/dist/pull-request-flow.d.ts +4 -0
  52. package/dist/pull-request-flow.js +16 -1
  53. package/dist/reply-voice.d.ts +23 -0
  54. package/dist/reply-voice.js +37 -0
  55. package/dist/scope.d.ts +6 -1
  56. package/dist/scope.js +3 -2
  57. package/dist/server/chat.js +4 -4
  58. package/dist/server/chrome.js +2 -2
  59. package/dist/server/home.d.ts +4 -3
  60. package/dist/server/home.js +33 -28
  61. package/dist/server/render-chat.js +11 -6
  62. package/dist/server/render-pages.js +2 -2
  63. package/dist/setup-guide.d.ts +2 -2
  64. package/dist/store.d.ts +4 -2
  65. package/dist/store.js +35 -12
  66. package/dist/telegram-lead.js +69 -5
  67. package/dist/telegram-status.js +9 -4
  68. package/dist/telegram.d.ts +10 -0
  69. package/dist/telegram.js +92 -8
  70. package/dist/toolroll-update.js +3 -1
  71. package/dist/tui-contract.d.ts +2 -0
  72. package/dist/tui-source.js +3 -0
  73. package/dist/tui.js +41 -41
  74. package/dist/typed-start.d.ts +51 -0
  75. package/dist/typed-start.js +98 -0
  76. package/dist/wake-hub.d.ts +72 -0
  77. package/dist/wake-hub.js +151 -0
  78. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  import { telegramCardMessage } from "./card-render.js";
2
2
  import { cardOfProposal, cardPlainOf, cardsFirst, cardWithin, isCardProposal } from "./chat-card.js";
3
3
  import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
4
- import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
4
+ import { typedStartOf } from "./typed-start.js";
5
+ import { armedQuestion, taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, NO_PHONE_LINK, NO_TASK_LINK, phoneProposalLink, confirmedLink, startedTaskLink, phoneProposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
5
6
  export { channelRepos as telegramConversationRepos, resolveChannelLead as resolveTelegramLead, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
6
7
  /**
7
8
  * The paired phone as one more way to use the same assistant.
@@ -26,7 +27,7 @@ export { channelRepos as telegramConversationRepos, resolveChannelLead as resolv
26
27
  */
27
28
  import { createHash, randomBytes } from "node:crypto";
28
29
  import { leadFailureText, runLeadTurn } from "./lead.js";
29
- import { confirmLeadProposal, dismissLeadProposal } from "./lead-doors.js";
30
+ import { confirmLeadProposal, confirmTypedStart, dismissLeadProposal, TYPED_START_STARTED } from "./lead-doors.js";
30
31
  import { ceilingDigestOf, verifyApproverStanding } from "./principal.js";
31
32
  import { LEAD_ASK_OTHER } from "./store.js";
32
33
  import { phoneText } from "./telegram-status.js";
@@ -129,7 +130,10 @@ export function mintCardTokens(store, binding, proposal, now, messageId, chatId
129
130
  for (const [token, phase] of [[confirm, "confirm"], [dismiss, "dismiss"]]) {
130
131
  store.createTelegramProposalAction({ token, binding: binding.id, proposal, phase, chatId, ttlMs: CARD_TTL_MS, ...(messageId === undefined ? {} : { messageId }) }, now);
131
132
  }
132
- return { keyboard: [[telegramButton("Confirm", confirm), telegramButton("Dismiss", dismiss)]], tokens: [confirm, dismiss] };
133
+ // A Start card's button says what it does: the card's last line tells the person to tap it (or reply yes).
134
+ const saved = store.getLeadProposal(proposal);
135
+ const label = saved?.kind === "action" && sharedActionPayload(saved.payload)?.operation === "task_start" ? "Start work" : "Confirm";
136
+ return { keyboard: [[telegramButton(label, confirm), telegramButton("Dismiss", dismiss)]], tokens: [confirm, dismiss] };
133
137
  }
134
138
  /**
135
139
  * One tap on a proposal card, applied inside the update's own transaction
@@ -249,7 +253,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
249
253
  }
250
254
  const irreversible = proposal.kind === "answer" && proposal.payload["reversible"] === false;
251
255
  const sharedAction = proposal.kind === "action" ? sharedActionPayload(proposal.payload) : null;
252
- const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction);
256
+ const challenged = sharedAction !== null && sharedActionAllowsChallenge(sharedAction, "telegram");
253
257
  // The arm: two fresh one-time tokens make a real challenge, exactly as a
254
258
  // decision button does. Nothing is answered or recorded here.
255
259
  const arm = () => {
@@ -573,7 +577,11 @@ async function runTelegramConversation(row, args) {
573
577
  const proposal = part.kind === "card" && part.proposal !== null ? store.getLeadProposal(part.proposal) : null;
574
578
  const origin = proposal === null ? null : options.phoneOrigin?.() ?? null;
575
579
  const wanted = proposal === null ? null : phoneProposalLink(store, proposal, repos);
576
- const keyboard = keyboardWith(part.keyboard, phoneLinkButton(origin, wanted));
580
+ // A typed yes that started a task (one-step-chat.md C5): Toolroll's reply carries the task's link, as a tapped
581
+ // card does. From the record and the origin read now, like every link.
582
+ const yes = part.kind === "reply" ? typedStartOf(store, "telegram", `m${row.id}`) : null;
583
+ const started = yes !== null && yes.outcome === "started" && yes.taskId !== null ? phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, yes.taskId, repos)) : null;
584
+ const keyboard = keyboardWith(part.keyboard, started ?? phoneLinkButton(origin, wanted));
577
585
  // A Toolroll card: read again now, shown only where every project behind it is still reachable. An unreadable
578
586
  // one goes out as its saved plain words.
579
587
  const isCard = proposal !== null && isCardProposal(proposal);
@@ -751,6 +759,62 @@ async function runTelegramConversation(row, args) {
751
759
  return;
752
760
  }
753
761
  const repos = telegramConversationRepos(store, binding.approver, registry);
762
+ // A typed yes to a Start card (docs/plans/one-step-chat.md C5), recorded when the message arrived: Toolroll acts on it
763
+ // here and no lead turn runs for it. Its outcome and Toolroll's own reply are written in ONE transaction (the claim
764
+ // fences the reply; a lapsed claim takes the whole act back), then sent like any reply. A settled record returns its
765
+ // first outcome, so a redelivery or a crash starts nothing more.
766
+ const typed = typedStartOf(store, "telegram", `m${row.id}`);
767
+ if (typed !== null) {
768
+ const verified = verifyApproverStanding(store, binding.approver, binding.approverGeneration, repos);
769
+ // The card's turn is gone when the chat was ended (its proposals are deleted) or its project deleted: there is then
770
+ // no session to send a reply under, and nothing can have started. Toolroll says so itself. The yes is never passed on.
771
+ const unanchored = { said: null };
772
+ try {
773
+ store.transact(() => {
774
+ const now = clock();
775
+ const answered = confirmTypedStart(store, verified.ok ? verified.who : null, { provider: "telegram", event: typed.event }, now, { binding: binding.id, evidenceRoot: options.evidenceRoot });
776
+ // The reply is fenced on the session the card was composed under.
777
+ const card = store.getLeadProposal(typed.proposal);
778
+ const turn = card === null ? null : store.getLeadTurn(card.turn);
779
+ if (turn === null) {
780
+ // A start without its card can't happen (the door re-reads the card); if it ever did, take it back.
781
+ if (answered.ok)
782
+ throw new PlanRefused();
783
+ unanchored.said = answered.said;
784
+ return;
785
+ }
786
+ // The card is answered: its buttons are spent with it.
787
+ if (answered.ok)
788
+ store.consumeTelegramProposalActions(typed.proposal, now);
789
+ if (!store.planTelegramConversationParts(row.id, owner, { session: turn.session, turn: turn.id }, [{ kind: "reply", text: answered.said, replyTo: row.messageId }], now))
790
+ throw new PlanRefused();
791
+ });
792
+ }
793
+ catch (error) {
794
+ if (error instanceof PlanRefused)
795
+ return;
796
+ throw error;
797
+ }
798
+ if (unanchored.said !== null) {
799
+ await notify(unanchored.said);
800
+ finish({ state: "failed", outcome: "typed-start:not-counted" });
801
+ report.refused++;
802
+ return;
803
+ }
804
+ // The card it answered is repainted as a tap's would be, best effort: its buttons are already spent, and the
805
+ // reply below says the same words with the same link.
806
+ const settled = typedStartOf(store, "telegram", typed.event);
807
+ if (settled?.outcome === "started" && settled.taskId !== null && pairingProblem() === null && held()) {
808
+ const link = phoneLinkButton(options.phoneOrigin?.() ?? null, startedTaskLink(store, settled.taskId, repos));
809
+ try {
810
+ await transport("editMessageText", { chat_id: chatId, message_id: Number(typed.cardMessage), text: `โœ“ ${TYPED_START_STARTED}`, link_preview_options: { is_disabled: true },
811
+ reply_markup: { inline_keyboard: link === null ? [] : [link] } });
812
+ }
813
+ catch { /* the reply below still says it */ }
814
+ }
815
+ await sendOrHandOff("typed-start");
816
+ return;
817
+ }
754
818
  // 4. A session bound before an earlier dispatch: the receipt lives there,
755
819
  // whatever session is live today.
756
820
  if (row.session !== null) {
@@ -8,6 +8,8 @@ import { familyCompleted } from "./result-completion.js";
8
8
  import { approvedCheckOf, approvedCheckStateOf, assignmentStatusFacts, checksFactOf, plainReasonOf, stageOfDispatch, taskStatusOf } from "./task-status.js";
9
9
  import { assignmentChecksForRun, assignmentOf } from "./assignment.js";
10
10
  import { CHAT_CONTROLS, chatControlHref, chatResultHref } from "./chat-controls.js";
11
+ import { homeFiguresLine, homeFiguresOf } from "./home-groups.js";
12
+ import { workIndexPage } from "./work-index.js";
11
13
  export function phoneCommand(text) {
12
14
  // Slash forms everywhere; the bare words too, because Slack and Discord
13
15
  // keep unregistered slash text for themselves. Only a whole message that
@@ -106,10 +108,13 @@ function nextStep(d) {
106
108
  * history. This snapshot is intentionally bounded and advertises that bound. */
107
109
  export function phoneStatus(store, repos, now, focused = null, root) {
108
110
  if (repos.length === 0)
109
- return "No connected projects are available to this bridge. Add a project in Toolroll, then send /status again.";
111
+ return "No projects are connected here yet. Add a project in Toolroll, then send /status again.";
110
112
  return store.transact(() => {
111
113
  const snapshot = withDispatchDiagnoses(store, store.chatSnapshot(repos, now), now);
112
- const lines = [...(focused === null ? [] : [`Talking about: ${focused} ยท /lead to switch back`, ""]), "Recent work", `As of ${now.toISOString().replace("T", " ").slice(0, 19)} UTC ยท ${repos.length} project(s)`, ""];
114
+ // It opens with Home's figures, from the work index totals over these projects (the Tasks lists' own counts);
115
+ // the groups below are dispatch diagnoses, which are not the work index's families, so they keep their headings.
116
+ const figures = homeFiguresOf(workIndexPage(store, now, { principal: "operator", repos, includeUnplaced: false }, { limit: 1, ...(root === undefined ? {} : { root }) }).totals);
117
+ const lines = [homeFiguresLine(figures), "", ...(focused === null ? [] : [`Talking about: ${focused} ยท /lead to switch back`, ""]), "Recent work", `As of ${now.toISOString().replace("T", " ").slice(0, 19)} UTC ยท ${repos.length} project(s)`, ""];
113
118
  if (snapshot.tasksSaturated)
114
119
  lines.push("Newest 60 tasks only โ€” older work may still need attention.", "");
115
120
  if (snapshot.tasks.length === 0)
@@ -126,7 +131,7 @@ export function phoneStatus(store, repos, now, focused = null, root) {
126
131
  lines.push(` +${rows.length - 2} more in the console`);
127
132
  lines.push("");
128
133
  }
129
- lines.push("Send /tasks to pick one, or /task <name> for its next step. Status is a snapshot, not a promise that the next attempt will succeed.");
134
+ lines.push("Send /tasks to pick one, or /task <name> for its next step. This is how things stand right now.");
130
135
  return lines.join("\n");
131
136
  });
132
137
  }
@@ -251,7 +256,7 @@ export function phoneTaskView(store, repos, id, now, root) {
251
256
  lines.push(`Attempt ended: ${plain(latest.finishedAt, 40)}`);
252
257
  else if (d.condition === "running") {
253
258
  const checkpoint = store.latestCheckpointForRun(latest.id);
254
- lines.push(checkpoint === null ? "No milestone progress has been recorded for this attempt." : `Latest agent-reported milestone update: ${plain(checkpoint.createdAt, 40)} (not independent proof).`);
259
+ lines.push(checkpoint === null ? "No milestone progress has been recorded for this attempt." : `Latest update from the agent, not verified: ${plain(checkpoint.createdAt, 40)}.`);
255
260
  }
256
261
  }
257
262
  let link = null;
@@ -148,6 +148,8 @@ export type BridgeReport = {
148
148
  contention?: number;
149
149
  /** v98: Telegram answered that it pushes this bot's updates to a webhook. */
150
150
  webhookActive?: boolean;
151
+ /** The long poll was ended on purpose because a message was due to go out (live-wakeups.md): not a failure. */
152
+ deliveryWake?: boolean;
151
153
  /** v98: pushed updates applied this pass. */
152
154
  pushed?: number;
153
155
  };
@@ -242,6 +244,8 @@ export declare function followBridge(store: Store, options: {
242
244
  } | null;
243
245
  /** v98: how often to check Telegram still pushes to us (tests shorten it). */
244
246
  pushCheckMs?: number;
247
+ /** While a long poll is open, how often to look for a message due to go out (tests shorten it). */
248
+ deliveryCheckMs?: number;
245
249
  }): Promise<FollowReport>;
246
250
  /** The digest text: a header with the count and the window, then one
247
251
  * fact per entry โ€” its subject, then its body's first line, indented.
@@ -260,3 +264,9 @@ export declare function digestText(rows: readonly Notification[], since: string
260
264
  export declare const TELEGRAM_MAX_WAITING = 10;
261
265
  /** A pushed update was kept for this bot: wake its bridge if it runs in this process (docs/plans/responsive-chat.md ยง3.1). */
262
266
  export declare function wakeTelegramBridge(botId: string): void;
267
+ /**
268
+ * Whether a message for this bot was written after `mark` and is still due: unresolved, addressed to everyone or to a
269
+ * person paired with this bot, and the bot not held back by Telegram's rate limit. Read-only; delivery itself still
270
+ * decides each row under its own fences.
271
+ */
272
+ export declare function telegramOutboxDue(store: Store, botId: string, mark: number, now: Date): boolean;
package/dist/telegram.js CHANGED
@@ -18,6 +18,7 @@
18
18
  * BotFather token plus `bridge telegram unpair` is the recovery, and both
19
19
  * are one command.
20
20
  */
21
+ import { recordTypedStart, typedStartCard, typedYes } from "./typed-start.js";
21
22
  import { telegramCardMessage } from "./card-render.js";
22
23
  import { flowDoneCard } from "./flow-result-card.js";
23
24
  import { applyTelegramQuestionReply, applyTelegramQuestionTap, openQuestionOf, telegramQuestionButtons } from "./subagent-question.js";
@@ -352,6 +353,8 @@ export const MAX_POLL_SECONDS = 25;
352
353
  const FOLLOW_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
353
354
  /** A cycle that returned instantly with nothing is padded to this โ€” a scripted or broken server must not spin the loop hot. */
354
355
  const FOLLOW_IDLE_FLOOR_MS = 1_000;
356
+ /** While a long poll is open, how often the follower looks for a message due to go out (live-wakeups.md). */
357
+ const DELIVERY_CHECK_MS = 1_000;
355
358
  /** v98: how often the bridge checks Telegram still pushes to us; how long pushes may keep failing before it asks
356
359
  * for updates itself; and how long it asks before trying pushes again. */
357
360
  const PUSH_CHECK_MS = 60_000, PUSH_GIVE_UP_MS = 5 * 60_000, PUSH_RETRY_MS = 15 * 60_000, PUSH_IDLE_MS = 2_000;
@@ -532,6 +535,8 @@ export async function followBridge(store, options) {
532
535
  const startedAt = Date.now();
533
536
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
534
537
  await managePush(report);
538
+ // What this cycle's delivery below covers: anything newer may end the long poll early.
539
+ const deliveredThrough = newestNotificationIdOf(store);
535
540
  if (options.deliver !== false) {
536
541
  await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
537
542
  await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
@@ -547,7 +552,43 @@ export async function followBridge(store, options) {
547
552
  report.pushed = pushed;
548
553
  if (!pushing()) {
549
554
  // While a turn is running, poll briefly so its reply is sent within a second or two of being written.
550
- await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
555
+ const seconds = inFlight === null ? pollSeconds : 1;
556
+ // A DELIVERY WAKE (live-wakeups.md): while the long poll is open, a message written for this bot since this
557
+ // cycle began delivering ends the poll, once per cycle, so it goes out now instead of after the poll. Same
558
+ // owner, same generation, one connection; the cursor moves only past updates already processed.
559
+ const poll = new AbortController();
560
+ const endPoll = () => poll.abort();
561
+ if (signal.aborted)
562
+ poll.abort();
563
+ else
564
+ signal.addEventListener("abort", endPoll, { once: true });
565
+ let woke = false;
566
+ const checker = options.deliver !== false && seconds > 1
567
+ ? setInterval(() => {
568
+ if (woke || poll.signal.aborted)
569
+ return;
570
+ let due = false;
571
+ try {
572
+ due = telegramOutboxDue(store, botId, deliveredThrough, clock());
573
+ }
574
+ catch {
575
+ due = false;
576
+ }
577
+ if (!due)
578
+ return;
579
+ woke = true;
580
+ poll.abort();
581
+ }, options.deliveryCheckMs ?? DELIVERY_CHECK_MS)
582
+ : null;
583
+ checker?.unref?.();
584
+ try {
585
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, seconds, poll.signal, options.readProjects, options.conversation, () => woke && !signal.aborted);
586
+ }
587
+ finally {
588
+ if (checker !== null)
589
+ clearInterval(checker);
590
+ signal.removeEventListener("abort", endPoll);
591
+ }
551
592
  // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
552
593
  if (report.webhookActive === true && push === null)
553
594
  await transport("deleteWebhook", { drop_pending_updates: false }, signal);
@@ -587,7 +628,8 @@ export async function followBridge(store, options) {
587
628
  const took = Date.now() - startedAt;
588
629
  // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
589
630
  const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
590
- if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
631
+ // A delivery wake goes straight back round to send: no padding.
632
+ if (!signal.aborted && report.deliveryWake !== true && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
591
633
  // While Telegram pushes, a push kept by this process's hook ends the idle at once.
592
634
  await (pushing() ? idle(floor - took) : wait(floor - took));
593
635
  }
@@ -1484,7 +1526,9 @@ function taskOf(store, decision) {
1484
1526
  const run = store.getRun(decision.run);
1485
1527
  return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
1486
1528
  }
1487
- async function drainUpdates(store, botId, transport, owner, generation, cursor, clock, report, pollSeconds = 0, signal, readProjects, conversation) {
1529
+ async function drainUpdates(store, botId, transport, owner, generation, cursor, clock, report, pollSeconds = 0, signal, readProjects, conversation,
1530
+ /** True when the poll was ended on purpose for a delivery: an answer that failed then is that abort, not the wire. */
1531
+ deliveryWake) {
1488
1532
  const context = { store, botId, transport, clock, report, readProjects, conversation, projects: null };
1489
1533
  let offset = cursor + 1;
1490
1534
  for (let page = 0; page < PAGE_BUDGET; page++) {
@@ -1495,6 +1539,12 @@ async function drainUpdates(store, botId, transport, owner, generation, cursor,
1495
1539
  allowed_updates: ["message", "callback_query"],
1496
1540
  }, signal);
1497
1541
  if (!answer.ok) {
1542
+ // Ended on purpose so a due message goes out now: no problem, no backoff; the next cycle asks again from the
1543
+ // same cursor, so nothing is lost.
1544
+ if (deliveryWake?.() === true) {
1545
+ report.deliveryWake = true;
1546
+ return;
1547
+ }
1498
1548
  const said = answer.description ?? "failed";
1499
1549
  // v98: another program asked Telegram for this bot's updates, and Telegram ended our
1500
1550
  // request in its favour. Nothing is lost: an update is only gone once someone
@@ -1723,7 +1773,14 @@ function applyMessage(context, update, effects) {
1723
1773
  return;
1724
1774
  // Replies to decisions retain their existing note meaning, even if the
1725
1775
  // note starts with a slash. New read commands are direct messages only.
1726
- if (message.reply_to_message === undefined && applyPhoneRead(context, update, effects))
1776
+ // A bare "start" as the very next message after a Start card is that card's yes (docs/plans/one-step-chat.md C5),
1777
+ // not the help command. Only the routing is decided here, by the same ids the message path reads again when it
1778
+ // records the yes; "/start", and "start" anywhere else, stay help.
1779
+ const yesSeat = message.reply_to_message === undefined && chat.type === "private" && typeof message.text === "string" && typedYes(message.text)
1780
+ && context.conversation !== undefined && context.readProjects !== undefined ? store.liveTelegramBindingFor(botId, String(from.id)) : null;
1781
+ const answersStartCard = yesSeat !== null && String(chat.id) === yesSeat.chatId
1782
+ && typedStartCard(store, { binding: yesSeat.id, chatId: yesSeat.chatId, approver: yesSeat.approver }, { id: String(message.message_id), replyTo: null }) !== null;
1783
+ if (message.reply_to_message === undefined && !answersStartCard && applyPhoneRead(context, update, effects))
1727
1784
  return;
1728
1785
  // A reply to a decision message this bot sent is a note, exactly as
1729
1786
  // before. Everything else that is ordinary text talks to the shared
@@ -1902,10 +1959,19 @@ function applyConversation(context, update, effects) {
1902
1959
  report.ignored++;
1903
1960
  return;
1904
1961
  }
1905
- store.enqueueTelegramConversation({
1906
- binding, updateId: update.update_id, messageId: String(message.message_id), replyTo,
1907
- request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
1908
- }, clock());
1962
+ // A typed yes to a Start card (docs/plans/one-step-chat.md C5) is read here, before any model runs, and recorded with
1963
+ // the message in the same transaction: the fixed words only, tied to one exact card by message ids and Toolroll's own
1964
+ // delivery record. The worker acts on it (confirmTypedStart) and no lead turn runs for it. Anything else is queued for
1965
+ // the lead exactly as before.
1966
+ store.transact(() => {
1967
+ const typed = typedYes(text) ? typedStartCard(store, { binding: binding.id, chatId: binding.chatId, approver: binding.approver }, { id: String(message.message_id), replyTo }) : null;
1968
+ store.enqueueTelegramConversation({
1969
+ binding, updateId: update.update_id, messageId: String(message.message_id), replyTo,
1970
+ request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
1971
+ }, clock());
1972
+ if (typed !== null)
1973
+ recordTypedStart(store, `m${update.update_id}`, typed, clock());
1974
+ });
1909
1975
  report.chatQueued = (report.chatQueued ?? 0) + 1;
1910
1976
  // Saved: ๐Ÿ‘€ on the owner's message right away (docs/plans/responsive-chat.md ยง3.1), once this update's transaction
1911
1977
  // has committed. It comes off when the answer or the failure notice is delivered. Never awaited: a burst of messages
@@ -2653,3 +2719,21 @@ export function wakeTelegramBridge(botId) {
2653
2719
  for (const wake of wakers)
2654
2720
  wake();
2655
2721
  }
2722
+ /** The newest notification id now: a follower cycle's mark for what its delivery covers. */
2723
+ function newestNotificationIdOf(store) {
2724
+ return Number(store.handle.prepare("SELECT COALESCE(MAX(id), 0) AS id FROM notification").get()?.["id"] ?? 0);
2725
+ }
2726
+ /**
2727
+ * Whether a message for this bot was written after `mark` and is still due: unresolved, addressed to everyone or to a
2728
+ * person paired with this bot, and the bot not held back by Telegram's rate limit. Read-only; delivery itself still
2729
+ * decides each row under its own fences.
2730
+ */
2731
+ export function telegramOutboxDue(store, botId, mark, now) {
2732
+ const approvers = [...new Set(store.liveTelegramBindings(botId).map(binding => binding.approver))];
2733
+ if (approvers.length === 0)
2734
+ return false;
2735
+ if (store.telegramRetryAt(botId) > now.toISOString())
2736
+ return false;
2737
+ return store.handle.prepare(`SELECT 1 AS hit FROM notification WHERE id > ? AND resolved_at IS NULL
2738
+ AND (recipient IS NULL OR recipient IN (SELECT value FROM json_each(?))) LIMIT 1`).get(mark, JSON.stringify(approvers)) !== undefined;
2739
+ }
@@ -44,6 +44,7 @@ import { orphanKey, updateSafeSchema, V117_RENAMED_COLUMNS, V117_RENAMED_TABLES,
44
44
  import { V152_ADDED_COLUMNS } from "./code-map-store.js";
45
45
  import { V153_ADDED_COLUMNS } from "./plain-schema.js";
46
46
  import { V158_ADDED_COLUMNS } from "./lead-latency-schema.js";
47
+ import { V159_ADDED_COLUMNS } from "./one-step-chat-schema.js";
47
48
  import { isNewer, REGISTRY } from "./releases.js";
48
49
  import { markNeverIndex } from "./never-index.js";
49
50
  import { readRuntimeUpdateJournal, RUNTIME_PHASES, RUNTIME_UPDATE_STEPS, stagedStartedAt, updaterStartingOf } from "./contracts/update-journal.js";
@@ -432,7 +433,8 @@ const HISTORY_RULES = {
432
433
  // v156 adds tables only (fallback chains): likewise.
433
434
  // v158 (after v157's chat-app tables): a saved lead step reads no tool names and no failure detail, and a saved reply no route and no failure detail
434
435
  // (NULL); the quick-answer switch is a new table.
435
- ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS, V150_ADDED_COLUMNS, V151_ADDED_COLUMNS, V152_ADDED_COLUMNS, V153_ADDED_COLUMNS, V158_ADDED_COLUMNS))
436
+ // v159: a saved fast-start preview reads no lead turn (NULL); a typed yes's record is a new table.
437
+ ...Object.fromEntries(Object.entries(addedColumns(V119_ADDED_COLUMNS, V120_ADDED_COLUMNS, V121_ADDED_COLUMNS, V122_ADDED_COLUMNS, V123_ADDED_COLUMNS, V125_ADDED_COLUMNS, V126_ADDED_COLUMNS, V128_ADDED_COLUMNS, V129_ADDED_COLUMNS, V134_ADDED_COLUMNS, V135_ADDED_COLUMNS, V138_ADDED_COLUMNS, V143_ADDED_COLUMNS, V147_ADDED_COLUMNS, V148_ADDED_COLUMNS, V150_ADDED_COLUMNS, V151_ADDED_COLUMNS, V152_ADDED_COLUMNS, V153_ADDED_COLUMNS, V158_ADDED_COLUMNS, V159_ADDED_COLUMNS))
436
438
  .map(([table, rule]) => [table, { ...EARLIER_RULES[table], ...rule }])),
437
439
  };
438
440
  /** The tables a migration may remove whole (v115's removed features). */
@@ -17,6 +17,8 @@ export type TuiRow = {
17
17
  /** The attempt running now, if any: what Stop names. */
18
18
  readonly liveRunId: number | null;
19
19
  readonly repo: string | null;
20
+ /** Home's group (home-groups.ts homeGroupOf): waits on you, working, done, or none (cancelled, replaced). */
21
+ readonly group: "you" | "working" | "done" | null;
20
22
  };
21
23
  /** A parked question on a task, answered by choosing one of its options. */
22
24
  export type TuiDecision = {
@@ -18,6 +18,7 @@ import { localModeOf, openStoreReadOnly } from "./store.js";
18
18
  import { routeChangeOf } from "./route-change.js";
19
19
  import { flakyCount } from "./flaky.js";
20
20
  import { memoryWaiting } from "./lesson-notes.js";
21
+ import { homeGroupOf } from "./home-groups.js";
21
22
  import { terminalText } from "./terminal-text.js";
22
23
  import { welcomeHere, welcomeLines } from "./welcome-facts.js";
23
24
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -74,6 +75,8 @@ export function createTuiSource(options) {
74
75
  return {
75
76
  id: words(row["id"], words(row["rootId"])), title: shown(row["title"]), status: shown(status["label"], words(row["state"])), detail: shown(status["detail"]),
76
77
  needsYou: views.includes("needs-you"), liveRunId: typeof row["liveRunId"] === "number" ? row["liveRunId"] : null, repo: typeof row["repo"] === "string" ? row["repo"] : null,
78
+ // The same rule as Home's groups, over the same work index item.
79
+ group: homeGroupOf({ status: { views: views.filter((one) => typeof one === "string") } }),
77
80
  };
78
81
  });
79
82
  const waiting = rows.filter(one => one.needsYou);