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
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The paired phone's live reply (docs/plans/responsive-chat.md §3.1): a 👀 on the owner's message as soon as it is
3
+ * saved, and while the lead works one Bot API message draft (`sendMessageDraft`, private chats only) that shows the
4
+ * status line and the reply as it is written.
5
+ *
6
+ * Nothing here is durable or authoritative. A draft is Telegram's "temporary 30-second preview": it disappears when the
7
+ * bot sends a message or when it is not refreshed, so a draft that is never replaced is harmless. The answer itself is
8
+ * still the planned, receipted `sendMessage` parts (telegram-lead.ts), sent only after this turn's sender has closed.
9
+ * Drafts carry words only: no keyboard, no card, no token. Every draft that carries more than Telegram's own empty
10
+ * "Thinking…" placeholder is sent only after the caller's full standing check (live pairing, approver, the project
11
+ * ceiling the turn opened under, the turn still running under its generation, the row's claim).
12
+ *
13
+ * Rate: Telegram's own retry_after is shared with every other send (`telegramRetryAt` / `deferTelegram`). While it
14
+ * holds, an ack waits and a draft is dropped (the answer and failure notices go first, acks next, drafts last); a
15
+ * draft that is asked to pause widens this turn's spacing.
16
+ */
17
+ import type { LeadProgress } from "./lead-progress.js";
18
+ import { type LiveView } from "./live-reply.js";
19
+ import { type ShapeOptions, type TelegramEntity } from "./reply-shape.js";
20
+ import type { Store } from "./store.js";
21
+ import type { TelegramTransport } from "./telegram.js";
22
+ /** The acknowledgement on the owner's message: "seen, working on it". Removed once the answer or the failure notice is
23
+ * delivered; never replaced by a "done" mark (an answer isn't finished work). */
24
+ export declare const ACK_EMOJI = "\uD83D\uDC40";
25
+ /** At most one reaction change per chat this often; a burst waits its turn, in order. */
26
+ export declare const ACK_GAP_MS = 1000;
27
+ /** Drafts start this far apart and widen (doubling, up to the cap) whenever Telegram asks for a pause. */
28
+ export declare const DRAFT_SPACING_MS = 1200;
29
+ /** Telegram keeps a draft about 30 seconds: one that has not changed is sent again after this long. */
30
+ export declare const DRAFT_KEEPALIVE_MS = 25000;
31
+ /** Telegram's message limit, counted after entities (UTF-16 units, as JS strings are). */
32
+ export declare const TELEGRAM_TEXT_MAX = 4096;
33
+ /** Test seams for the timings above (production uses the defaults). */
34
+ export type TelegramLiveTiming = {
35
+ ackGapMs?: number;
36
+ draftSpacingMs?: number;
37
+ keepAliveMs?: number;
38
+ };
39
+ export type DraftEntity = TelegramEntity | {
40
+ type: "italic";
41
+ offset: number;
42
+ length: number;
43
+ };
44
+ export type DraftPayload = {
45
+ text: string;
46
+ entities: DraftEntity[];
47
+ };
48
+ /** The body of a writing draft within `budget` characters once rendered: cut at a safe place in the shaped text first
49
+ * (never inside code, a link, a bold anchor or an emoji), rendered, measured, and cut smaller if it still doesn't fit.
50
+ * A body that can't fit (a single link longer than the budget) shows nothing. */
51
+ export declare function draftBody(shaped: string, budget: number): DraftPayload;
52
+ /**
53
+ * One live view as a draft: the status line in italics, then (once text exists) the partial reply as text plus
54
+ * entities, never Markdown. Plain "Thinking…" is Telegram's own empty-text placeholder. Closed views send nothing.
55
+ */
56
+ export declare function telegramDraftOf(view: LiveView): DraftPayload | null;
57
+ /**
58
+ * Put 👀 on the owner's message (`on`) or take it off. Best effort and never awaited by the caller's durable work: a
59
+ * missing pairing, a failure or a long pause skips it silently. `paired` is re-checked at the moment of sending; a
60
+ * reaction carries no project data, so the pairing alone fences it. Resolves once this change was tried.
61
+ */
62
+ export declare function telegramAck(args: {
63
+ store: Store;
64
+ botId: string;
65
+ transport: TelegramTransport;
66
+ clock: () => Date;
67
+ chatId: string;
68
+ messageId: string;
69
+ on: boolean;
70
+ paired: () => boolean;
71
+ gapMs?: number;
72
+ }): Promise<void>;
73
+ export type TelegramLiveDraft = {
74
+ /** The turn's progress listener (runLeadTurn's `onProgress`). */
75
+ onProgress: (event: LeadProgress) => void;
76
+ /** Stop: nothing more is sent, and the draft in flight has settled when this resolves. Durable delivery comes after. */
77
+ close: () => Promise<void>;
78
+ /** The turn the drafts follow, once it started. */
79
+ readonly turn: number | null;
80
+ };
81
+ /**
82
+ * One running turn's draft. `standing` is the caller's full proof for a draft that carries words (it may await; the
83
+ * close is re-checked after it); `paired` is enough for Telegram's empty "Thinking…". A draft Telegram refuses outright
84
+ * (not a pause, not a lost answer) turns this turn's drafts off; previews are best effort.
85
+ */
86
+ export declare function createTelegramLiveDraft(args: {
87
+ store: Store;
88
+ botId: string;
89
+ transport: TelegramTransport;
90
+ clock: () => Date;
91
+ chatId: string;
92
+ binding: number;
93
+ draftId: number;
94
+ shape: ShapeOptions;
95
+ standing: (turn: number) => Promise<boolean>;
96
+ paired: () => boolean;
97
+ timing?: TelegramLiveTiming;
98
+ }): TelegramLiveDraft;
99
+ /**
100
+ * A message waiting for the lead (busy, or behind the owner's earlier messages): "Waiting for your earlier messages…"
101
+ * as a draft, refreshed only after the keep-alive interval, and never while this chat's running turn has its own
102
+ * draft. Carries no project data: the pairing and the claim fence it.
103
+ */
104
+ export declare function telegramWaitingDraft(args: {
105
+ store: Store;
106
+ botId: string;
107
+ transport: TelegramTransport;
108
+ clock: () => Date;
109
+ chatId: string;
110
+ binding: number;
111
+ draftId: number;
112
+ allowed: () => boolean;
113
+ timing?: TelegramLiveTiming;
114
+ }): Promise<void>;
115
+ /** The message is answered or failed: its waiting draft is forgotten. */
116
+ export declare function forgetWaitingDraft(botId: string, draftId: number): void;
@@ -0,0 +1,224 @@
1
+ import { createLiveReply, createLiveSender, LIVE_STATUS } from "./live-reply.js";
2
+ import { shapedWindow, telegramReply } from "./reply-shape.js";
3
+ /** The acknowledgement on the owner's message: "seen, working on it". Removed once the answer or the failure notice is
4
+ * delivered; never replaced by a "done" mark (an answer isn't finished work). */
5
+ export const ACK_EMOJI = "👀";
6
+ /** At most one reaction change per chat this often; a burst waits its turn, in order. */
7
+ export const ACK_GAP_MS = 1_000;
8
+ /** A reaction change that meets Telegram's pause waits it out when it is this short; a longer pause skips it. */
9
+ const ACK_MAX_WAIT_MS = 30_000;
10
+ /** Drafts start this far apart and widen (doubling, up to the cap) whenever Telegram asks for a pause. */
11
+ export const DRAFT_SPACING_MS = 1_200;
12
+ const DRAFT_MAX_SPACING_MS = 10_000;
13
+ /** Telegram keeps a draft about 30 seconds: one that has not changed is sent again after this long. */
14
+ export const DRAFT_KEEPALIVE_MS = 25_000;
15
+ /** Telegram's message limit, counted after entities (UTF-16 units, as JS strings are). */
16
+ export const TELEGRAM_TEXT_MAX = 4_096;
17
+ /** The body of a writing draft within `budget` characters once rendered: cut at a safe place in the shaped text first
18
+ * (never inside code, a link, a bold anchor or an emoji), rendered, measured, and cut smaller if it still doesn't fit.
19
+ * A body that can't fit (a single link longer than the budget) shows nothing. */
20
+ export function draftBody(shaped, budget) {
21
+ if (budget <= 0)
22
+ return { text: "", entities: [] };
23
+ let size = budget;
24
+ for (let attempt = 0; attempt < 8 && size >= 1; attempt += 1) {
25
+ const window = shapedWindow(shaped, size);
26
+ const cut = window.length < shaped.length;
27
+ const rendered = telegramReply(cut ? window.trimEnd() : window);
28
+ const text = cut ? `${rendered.text} …` : rendered.text;
29
+ if (text.length <= budget)
30
+ return { text, entities: rendered.entities };
31
+ size = Math.min(size - 1, Math.floor(size * budget / text.length) - 2);
32
+ }
33
+ return { text: "", entities: [] };
34
+ }
35
+ /**
36
+ * One live view as a draft: the status line in italics, then (once text exists) the partial reply as text plus
37
+ * entities, never Markdown. Plain "Thinking…" is Telegram's own empty-text placeholder. Closed views send nothing.
38
+ */
39
+ export function telegramDraftOf(view) {
40
+ if (view.phase === "closed")
41
+ return null;
42
+ if (view.phase === "queued")
43
+ return { text: LIVE_STATUS.queued, entities: [{ type: "italic", offset: 0, length: LIVE_STATUS.queued.length }] };
44
+ if (view.phase === "working") {
45
+ if (view.status === LIVE_STATUS.thinking)
46
+ return { text: "", entities: [] };
47
+ return { text: view.status, entities: [{ type: "italic", offset: 0, length: view.status.length }] };
48
+ }
49
+ const head = view.status;
50
+ const gap = "\n\n";
51
+ const body = draftBody(view.text, TELEGRAM_TEXT_MAX - head.length - gap.length);
52
+ if (body.text === "")
53
+ return { text: head, entities: [{ type: "italic", offset: 0, length: head.length }] };
54
+ const shift = head.length + gap.length;
55
+ return {
56
+ text: `${head}${gap}${body.text}`,
57
+ entities: [{ type: "italic", offset: 0, length: head.length }, ...body.entities.map(one => ({ ...one, offset: one.offset + shift }))],
58
+ };
59
+ }
60
+ /** One draft on the wire, under the shared pause. A retry_after is recorded for every sender of this bot. */
61
+ async function sendDraft(store, botId, transport, clock, chatId, draftId, payload) {
62
+ if (store.telegramRetryAt(botId) > clock().toISOString())
63
+ return "paused";
64
+ let answer;
65
+ try {
66
+ answer = await transport("sendMessageDraft", { chat_id: Number(chatId), draft_id: draftId, text: payload.text, ...(payload.entities.length === 0 ? {} : { entities: payload.entities }) });
67
+ }
68
+ catch {
69
+ return "lost";
70
+ }
71
+ if (answer.ok)
72
+ return "sent";
73
+ const retry = answer.parameters?.retry_after;
74
+ if (typeof retry === "number" && Number.isFinite(retry) && retry > 0) {
75
+ store.deferTelegram(botId, new Date(clock().getTime() + Math.ceil(retry) * 1_000).toISOString());
76
+ return "paused";
77
+ }
78
+ return answer.uncertain === true ? "lost" : "refused";
79
+ }
80
+ // ---- the 👀 acknowledgement ----------------------------------------------------------
81
+ /** One lane per chat: reaction changes go out in order (an ack never lands after its own removal), spaced. */
82
+ const ackLanes = new Map();
83
+ const pause = (ms) => new Promise(resolve => { const timer = setTimeout(resolve, ms); timer.unref?.(); });
84
+ /**
85
+ * Put 👀 on the owner's message (`on`) or take it off. Best effort and never awaited by the caller's durable work: a
86
+ * missing pairing, a failure or a long pause skips it silently. `paired` is re-checked at the moment of sending; a
87
+ * reaction carries no project data, so the pairing alone fences it. Resolves once this change was tried.
88
+ */
89
+ export function telegramAck(args) {
90
+ const key = `${args.botId}:${args.chatId}`;
91
+ const lane = ackLanes.get(key) ?? { tail: Promise.resolve(), lastAt: Number.NEGATIVE_INFINITY };
92
+ ackLanes.set(key, lane);
93
+ const gap = args.gapMs ?? ACK_GAP_MS;
94
+ // Never throws: a store closed meanwhile (a stop) or anything else skips the change.
95
+ const run = () => change().catch(() => undefined);
96
+ const change = async () => {
97
+ const spaced = lane.lastAt + gap - Date.now();
98
+ if (spaced > 0)
99
+ await pause(spaced);
100
+ const held = Date.parse(args.store.telegramRetryAt(args.botId)) - args.clock().getTime();
101
+ if (Number.isFinite(held) && held > 0) {
102
+ if (held > ACK_MAX_WAIT_MS)
103
+ return;
104
+ await pause(held);
105
+ }
106
+ if (!args.paired())
107
+ return;
108
+ lane.lastAt = Date.now();
109
+ try {
110
+ const answer = await args.transport("setMessageReaction", {
111
+ chat_id: Number(args.chatId), message_id: Number(args.messageId), reaction: args.on ? [{ type: "emoji", emoji: ACK_EMOJI }] : [],
112
+ });
113
+ const retry = answer.ok ? undefined : answer.parameters?.retry_after;
114
+ if (typeof retry === "number" && Number.isFinite(retry) && retry > 0)
115
+ args.store.deferTelegram(args.botId, new Date(args.clock().getTime() + Math.ceil(retry) * 1_000).toISOString());
116
+ }
117
+ catch { /* skipped silently */ }
118
+ };
119
+ const done = lane.tail.then(run, run);
120
+ lane.tail = done;
121
+ // A finished lane is forgotten; the next change starts a fresh one (spacing restarts from now).
122
+ void done.then(() => { if (ackLanes.get(key) === lane && lane.tail === done && Date.now() - lane.lastAt >= gap)
123
+ ackLanes.delete(key); });
124
+ return done;
125
+ }
126
+ // ---- the draft --------------------------------------------------------------------------
127
+ /** Chats (bot:binding) whose turn has a live draft in this process: a waiting message's draft never covers it. */
128
+ const liveChats = new Set();
129
+ /** When each waiting message last had its "Waiting for your earlier messages…" draft (bot:row). */
130
+ const waitingDrafts = new Map();
131
+ /**
132
+ * One running turn's draft. `standing` is the caller's full proof for a draft that carries words (it may await; the
133
+ * close is re-checked after it); `paired` is enough for Telegram's empty "Thinking…". A draft Telegram refuses outright
134
+ * (not a pause, not a lost answer) turns this turn's drafts off; previews are best effort.
135
+ */
136
+ export function createTelegramLiveDraft(args) {
137
+ const reply = createLiveReply(args.shape);
138
+ const keepAlive = args.timing?.keepAliveMs ?? DRAFT_KEEPALIVE_MS;
139
+ const base = args.timing?.draftSpacingMs ?? DRAFT_SPACING_MS;
140
+ const chat = `${args.botId}:${args.binding}`;
141
+ let spacing = base;
142
+ let lastSentAt = Date.now();
143
+ let off = false;
144
+ liveChats.add(chat);
145
+ /** What the draft shows now: a preview closed as a possible password or key keeps only the status line. */
146
+ const current = () => {
147
+ const view = reply.view();
148
+ if (view.phase !== "closed")
149
+ return view;
150
+ return view.why === "blocked" ? { phase: "working", status: LIVE_STATUS.held } : null;
151
+ };
152
+ const sender = createLiveSender({
153
+ spacingMs: () => spacing,
154
+ send: async (view) => {
155
+ if (off)
156
+ return;
157
+ const payload = telegramDraftOf(view);
158
+ const turn = reply.turn;
159
+ if (payload === null || turn === null)
160
+ return;
161
+ const allowed = payload.text === "" ? args.paired() : await args.standing(turn);
162
+ if (!allowed || sender.closed)
163
+ return;
164
+ const outcome = await sendDraft(args.store, args.botId, args.transport, args.clock, args.chatId, args.draftId, payload);
165
+ if (outcome === "sent")
166
+ lastSentAt = Date.now();
167
+ else if (outcome === "paused")
168
+ spacing = Math.min(DRAFT_MAX_SPACING_MS, spacing * 2);
169
+ else if (outcome === "refused")
170
+ off = true;
171
+ },
172
+ });
173
+ const timer = setInterval(() => {
174
+ if (sender.closed || Date.now() - lastSentAt < keepAlive)
175
+ return;
176
+ const view = current();
177
+ if (view !== null)
178
+ sender.offer(view);
179
+ }, Math.max(50, Math.min(keepAlive, 5_000)));
180
+ timer.unref?.();
181
+ return {
182
+ get turn() { return reply.turn; },
183
+ onProgress: event => {
184
+ // The first event names the turn: Telegram's "Thinking…" shows at once, even before the view changes.
185
+ const first = reply.turn === null;
186
+ if (!reply.push(event) && !first)
187
+ return;
188
+ const view = current();
189
+ if (view !== null)
190
+ sender.offer(view);
191
+ },
192
+ close: async () => {
193
+ clearInterval(timer);
194
+ liveChats.delete(chat);
195
+ await sender.close();
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * A message waiting for the lead (busy, or behind the owner's earlier messages): "Waiting for your earlier messages…"
201
+ * as a draft, refreshed only after the keep-alive interval, and never while this chat's running turn has its own
202
+ * draft. Carries no project data: the pairing and the claim fence it.
203
+ */
204
+ export async function telegramWaitingDraft(args) {
205
+ const key = `${args.botId}:${args.draftId}`;
206
+ if (liveChats.has(`${args.botId}:${args.binding}`))
207
+ return;
208
+ const last = waitingDrafts.get(key);
209
+ if (last !== undefined && Date.now() - last < (args.timing?.keepAliveMs ?? DRAFT_KEEPALIVE_MS))
210
+ return;
211
+ if (!args.allowed())
212
+ return;
213
+ const payload = telegramDraftOf({ phase: "queued" });
214
+ if (await sendDraft(args.store, args.botId, args.transport, args.clock, args.chatId, args.draftId, payload) !== "sent")
215
+ return;
216
+ waitingDrafts.set(key, Date.now());
217
+ // Bounded: the oldest entries go first.
218
+ while (waitingDrafts.size > 500)
219
+ waitingDrafts.delete(waitingDrafts.keys().next().value);
220
+ }
221
+ /** The message is answered or failed: its waiting draft is forgotten. */
222
+ export function forgetWaitingDraft(botId, draftId) {
223
+ waitingDrafts.delete(`${botId}:${draftId}`);
224
+ }
@@ -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.
@@ -258,3 +262,11 @@ export declare function digestText(rows: readonly Notification[], since: string
258
262
  */
259
263
  /** At most this many of a chat's messages wait for the lead at once (the personal lead queue's bound). */
260
264
  export declare const TELEGRAM_MAX_WAITING = 10;
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). */
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";
@@ -47,6 +48,7 @@ import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_H
47
48
  import { LEAD_MESSAGE_MAX_CHARS } from "./lead.js";
48
49
  import { envValue } from "./names.js";
49
50
  import { applyProposalTap, processTelegramConversations, phoneLinkButton, replyContextFor, telegramConversationRepos, telegramRequestId, tooLongText, whichTaskText, } from "./telegram-lead.js";
51
+ import { telegramAck } from "./telegram-live.js";
50
52
  import { askOf, parseTelegramUpdate, pickOf, readTelegramButtonData, readTelegramUpdate, telegramButton } from "./contracts/telegram-callback.js";
51
53
  /** Read the enrolled project list on demand. No callback means no task data,
52
54
  * never an implicit all-database ceiling. Shared by pass and embedded follower. */
@@ -119,7 +121,11 @@ export function keepPushedUpdate(store, botId, body, now) {
119
121
  console.warn(`Ignoring a pushed Telegram update: ${update.issues.map(issue => issue.line).join("; ")}`);
120
122
  return { ok: true, kept: false };
121
123
  }
122
- return { ok: true, kept: store.queueTelegramUpdate(botId, update.value.update_id, JSON.stringify(raw), now) };
124
+ const kept = store.queueTelegramUpdate(botId, update.value.update_id, JSON.stringify(raw), now);
125
+ // The bridge in this process (if any) stops idling and drains it now; elsewhere it waits at most one cycle.
126
+ if (kept)
127
+ wakeTelegramBridge(botId);
128
+ return { ok: true, kept };
123
129
  }
124
130
  /**
125
131
  * Environment wins, the credential file beside the database otherwise.
@@ -347,6 +353,8 @@ export const MAX_POLL_SECONDS = 25;
347
353
  const FOLLOW_BACKOFF_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
348
354
  /** A cycle that returned instantly with nothing is padded to this — a scripted or broken server must not spin the loop hot. */
349
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;
350
358
  /** v98: how often the bridge checks Telegram still pushes to us; how long pushes may keep failing before it asks
351
359
  * for updates itself; and how long it asks before trying pushes again. */
352
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;
@@ -387,6 +395,21 @@ export async function followBridge(store, options) {
387
395
  }
388
396
  signal.addEventListener("abort", finish, { once: true });
389
397
  }));
398
+ /** The push idle: the ordinary wait, ended early when this process keeps a push for this bot. */
399
+ const idle = (ms) => {
400
+ if (bridgeWokenEarly.delete(botId))
401
+ return Promise.resolve();
402
+ let wake = () => { };
403
+ const woken = new Promise(resolve => { wake = resolve; });
404
+ const wakers = bridgeWakers.get(botId) ?? new Set();
405
+ bridgeWakers.set(botId, wakers);
406
+ wakers.add(wake);
407
+ return Promise.race([wait(ms), woken]).finally(() => {
408
+ wakers.delete(wake);
409
+ if (wakers.size === 0 && bridgeWakers.get(botId) === wakers)
410
+ bridgeWakers.delete(botId);
411
+ });
412
+ };
390
413
  const total = { cycles: 0, sent: 0, answered: 0, paired: 0, ignored: 0, problems: [] };
391
414
  let commandsListed = false;
392
415
  let failures = 0;
@@ -512,6 +535,8 @@ export async function followBridge(store, options) {
512
535
  const startedAt = Date.now();
513
536
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
514
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);
515
540
  if (options.deliver !== false) {
516
541
  await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
517
542
  await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
@@ -527,7 +552,43 @@ export async function followBridge(store, options) {
527
552
  report.pushed = pushed;
528
553
  if (!pushing()) {
529
554
  // While a turn is running, poll briefly so its reply is sent within a second or two of being written.
530
- 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
+ }
531
592
  // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
532
593
  if (report.webhookActive === true && push === null)
533
594
  await transport("deleteWebhook", { drop_pending_updates: false }, signal);
@@ -567,8 +628,10 @@ export async function followBridge(store, options) {
567
628
  const took = Date.now() - startedAt;
568
629
  // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
569
630
  const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
570
- if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
571
- await wait(floor - took);
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) {
633
+ // While Telegram pushes, a push kept by this process's hook ends the idle at once.
634
+ await (pushing() ? idle(floor - took) : wait(floor - took));
572
635
  }
573
636
  }
574
637
  }
@@ -1463,7 +1526,9 @@ function taskOf(store, decision) {
1463
1526
  const run = store.getRun(decision.run);
1464
1527
  return run === null ? "?" : store.externalIdFor(run.taskRef) ?? "?";
1465
1528
  }
1466
- 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) {
1467
1532
  const context = { store, botId, transport, clock, report, readProjects, conversation, projects: null };
1468
1533
  let offset = cursor + 1;
1469
1534
  for (let page = 0; page < PAGE_BUDGET; page++) {
@@ -1474,6 +1539,12 @@ async function drainUpdates(store, botId, transport, owner, generation, cursor,
1474
1539
  allowed_updates: ["message", "callback_query"],
1475
1540
  }, signal);
1476
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
+ }
1477
1548
  const said = answer.description ?? "failed";
1478
1549
  // v98: another program asked Telegram for this bot's updates, and Telegram ended our
1479
1550
  // request in its favour. Nothing is lost: an update is only gone once someone
@@ -1702,7 +1773,14 @@ function applyMessage(context, update, effects) {
1702
1773
  return;
1703
1774
  // Replies to decisions retain their existing note meaning, even if the
1704
1775
  // note starts with a slash. New read commands are direct messages only.
1705
- 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))
1706
1784
  return;
1707
1785
  // A reply to a decision message this bot sent is a note, exactly as
1708
1786
  // before. Everything else that is ordinary text talks to the shared
@@ -1881,11 +1959,33 @@ function applyConversation(context, update, effects) {
1881
1959
  report.ignored++;
1882
1960
  return;
1883
1961
  }
1884
- store.enqueueTelegramConversation({
1885
- binding, updateId: update.update_id, messageId: String(message.message_id), replyTo,
1886
- request: telegramRequestId(botId, binding.id, update.update_id), text, context: context_, taskId, sourceRun,
1887
- }, 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
+ });
1888
1975
  report.chatQueued = (report.chatQueued ?? 0) + 1;
1976
+ // Saved: 👀 on the owner's message right away (docs/plans/responsive-chat.md §3.1), once this update's transaction
1977
+ // has committed. It comes off when the answer or the failure notice is delivered. Never awaited: a burst of messages
1978
+ // waits its turn on the chat's own lane, not the drain.
1979
+ const gapMs = context.conversation?.liveTiming?.ackGapMs;
1980
+ effects.push(async () => {
1981
+ void telegramAck({
1982
+ store, botId, transport, clock, chatId: binding.chatId, messageId: String(message.message_id), on: true, ...(gapMs === undefined ? {} : { gapMs }),
1983
+ paired: () => {
1984
+ const live = store.liveTelegramBindingById(binding.id);
1985
+ return live !== null && live.approverGeneration === binding.approverGeneration && store.accountOf(live.approver)?.role === "approver";
1986
+ },
1987
+ });
1988
+ });
1889
1989
  }
1890
1990
  /** The picker's buttons: one task per row (its title and where it stands),
1891
1991
  * carried by the task's numeric reference so the data fits Telegram's 64
@@ -2605,3 +2705,35 @@ function expiredDraftGuard(store, binding, decisionId, now) {
2605
2705
  store.setNoteDraftState(Number(expired["id"]), "discarded");
2606
2706
  return true;
2607
2707
  }
2708
+ /** Bridges in this process waiting out their push idle, by bot: a kept push ends the wait early. */
2709
+ const bridgeWakers = new Map();
2710
+ /** Bots with a push kept while no bridge idled (it was mid-cycle): the next idle doesn't wait. */
2711
+ const bridgeWokenEarly = new Set();
2712
+ /** A pushed update was kept for this bot: wake its bridge if it runs in this process (docs/plans/responsive-chat.md §3.1). */
2713
+ export function wakeTelegramBridge(botId) {
2714
+ const wakers = bridgeWakers.get(botId);
2715
+ if (wakers === undefined || wakers.size === 0) {
2716
+ bridgeWokenEarly.add(botId);
2717
+ return;
2718
+ }
2719
+ for (const wake of wakers)
2720
+ wake();
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
+ }