toolroll 0.9.9 → 0.9.11

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.
package/dist/telegram.js CHANGED
@@ -29,7 +29,8 @@ import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
30
  import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
31
31
  import { telegramProgressCard } from "./telegram-progress.js";
32
- import { enqueueEveningDigests, isTaskFact, needsPerson, quietCardView, readyPingView } from "./chat-quiet.js";
32
+ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
33
+ import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
33
34
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
34
35
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
35
36
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
@@ -193,7 +194,7 @@ export function createTransport(token, timeoutMs = 30_000) {
193
194
  catch (error) {
194
195
  return {
195
196
  ok: false,
196
- description: scrub(error instanceof Error ? error.message : String(error), token),
197
+ description: scrub(transportError(error), token),
197
198
  uncertain: true,
198
199
  };
199
200
  }
@@ -203,6 +204,48 @@ export function createTransport(token, timeoutMs = 30_000) {
203
204
  }
204
205
  };
205
206
  }
207
+ /**
208
+ * Why this process can't reach Telegram the way the service does, or null.
209
+ * A coding agent's sandbox fences the network: Codex says so in its own
210
+ * variables, and Claude Code's sandbox routes traffic through a proxy on
211
+ * this computer, which curl uses and Node's fetch does not — so sends fail
212
+ * "fetch failed" while curl works (Oct 2). Builds, checks and agents also
213
+ * get a database of their own, so none of them can hold the live bridge;
214
+ * this keeps an agent-launched `watch` or `bridge` from holding it either.
215
+ */
216
+ export function networkFence(env) {
217
+ const set = (name) => (env[name] ?? "") !== "";
218
+ if (set("CODEX_SANDBOX_NETWORK_DISABLED") || set("CODEX_SANDBOX"))
219
+ return "a Codex sandbox";
220
+ const agent = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].some(set);
221
+ const loopbackProxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"].some(name => {
222
+ const value = env[name] ?? "";
223
+ if (value === "")
224
+ return false;
225
+ try {
226
+ return /^(?:localhost|127\.\d+\.\d+\.\d+|\[::1\])$/i.test(new URL(value.includes("://") ? value : `http://${value}`).hostname);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ });
232
+ return agent && loopbackProxy ? "a Claude Code sandbox" : null;
233
+ }
234
+ /** The plain line a fenced process says instead of polling or sending. */
235
+ export function networkFenceLine(reason) {
236
+ return `Telegram: not connecting from here — this process runs inside ${reason}, which blocks its network. Replies and notifications go out from the Toolroll service.`;
237
+ }
238
+ /** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
239
+ export function transportError(error) {
240
+ const message = error instanceof Error ? error.message : String(error);
241
+ const cause = error instanceof Error ? error.cause : undefined;
242
+ if (cause === undefined || cause === null)
243
+ return message;
244
+ const code = typeof cause.code === "string" ? cause.code : null;
245
+ const detail = cause instanceof Error ? cause.message : String(cause);
246
+ const why = code !== null && !detail.includes(code) ? `${code}: ${detail}` : detail;
247
+ return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
248
+ }
206
249
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
207
250
  export function multipartOf(params, upload) {
208
251
  const form = new FormData();
@@ -331,12 +374,16 @@ export async function followBridge(store, options) {
331
374
  // turn must not stall the long poll, and each cycle's lease re-acquire is
332
375
  // the renewal that keeps this the only live poller meanwhile. One
333
376
  // processor at a time; its counts land on the totals when it finishes.
377
+ // It only persists the reply: the cycle sends it, in the same slot as
378
+ // notifications, so a reply never needs a connection of its own beside
379
+ // the long poll (Oct 2: replies failed "fetch failed" for minutes while
380
+ // notifications from the same process went out).
334
381
  let inFlight = null;
335
382
  const kick = () => {
336
383
  if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
337
384
  return;
338
385
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
339
- inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
386
+ inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "turns")
340
387
  .catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
341
388
  .finally(() => {
342
389
  inFlight = null;
@@ -438,6 +485,8 @@ export async function followBridge(store, options) {
438
485
  await transport("setMyCommands", { commands: TELEGRAM_COMMANDS });
439
486
  }
440
487
  catch { /* typed commands still work */ }
488
+ // After an upgrade, a bot still called StandingOrders becomes Toolroll, once per start; any other name is kept.
489
+ await nameTelegramBot(transport, "upgrade");
441
490
  }
442
491
  const startedAt = Date.now();
443
492
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
@@ -446,13 +495,18 @@ export async function followBridge(store, options) {
446
495
  await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
447
496
  await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
448
497
  }
498
+ // Replies the turn worker has planned go out here, like notifications.
499
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
500
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "replies");
501
+ }
449
502
  // Pushed updates first (some may wait from before a fall back), then — only while
450
503
  // Telegram isn't pushing — ask for more.
451
504
  const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
452
505
  if (pushed > 0)
453
506
  report.pushed = pushed;
454
507
  if (!pushing()) {
455
- await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, pollSeconds, signal, options.readProjects, options.conversation);
508
+ // While a turn is running, poll briefly so its reply is sent within a second or two of being written.
509
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
456
510
  // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
457
511
  if (report.webhookActive === true && push === null)
458
512
  await transport("deleteWebhook", { drop_pending_updates: false }, signal);
@@ -471,11 +525,13 @@ export async function followBridge(store, options) {
471
525
  total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
472
526
  if (report.chatConfirmed !== undefined)
473
527
  total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
528
+ if (report.chatAnswered !== undefined)
529
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
474
530
  total.problems.push(...report.problems);
475
- if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
531
+ if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
476
532
  options.onCycle?.(report);
477
533
  }
478
- if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
534
+ if (report.problems.length > 0 && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0) {
479
535
  // The wire is down. Back off; the counter resets on the first clean cycle.
480
536
  if (!signal.aborted) {
481
537
  await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
@@ -490,7 +546,7 @@ export async function followBridge(store, options) {
490
546
  const took = Date.now() - startedAt;
491
547
  // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
492
548
  const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
493
- if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
549
+ if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
494
550
  await wait(floor - took);
495
551
  }
496
552
  }
@@ -498,37 +554,50 @@ export async function followBridge(store, options) {
498
554
  finally {
499
555
  // A turn in flight finishes on its own bounds (the engine's wall clock);
500
556
  // its reply is fenced on the claim and the channel like every other part.
501
- if (inFlight !== null)
557
+ if (inFlight !== null) {
502
558
  await inFlight;
559
+ // Its reply was written for the cycle that will not come: send it now, while the lease is still ours.
560
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
561
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
562
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, undefined, "replies").catch(() => { });
563
+ if (report.chatAnswered !== undefined)
564
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
565
+ total.problems.push(...report.problems);
566
+ }
567
+ }
503
568
  store.releaseBridgeLease(botId, owner, clock());
504
569
  }
505
570
  return total;
506
571
  }
507
572
  /** The queued turns for one bot, counted onto the pass's report. */
508
- async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
573
+ async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal, only) {
509
574
  const chat = { answered: 0, refused: 0, problems: [] };
510
- await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
575
+ await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }), ...(only === undefined ? {} : { only }) });
511
576
  if (chat.answered > 0)
512
577
  report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
513
578
  if (chat.refused > 0)
514
579
  report.chatRefused = (report.chatRefused ?? 0) + chat.refused;
515
580
  report.problems.push(...chat.problems);
516
581
  }
517
- /** The one button a plain fact may carry: its machine-minted console path
518
- * under the trusted origin read now — the same road `/task` uses. The label
519
- * names where the path goes; nothing in it comes from the fact's text. */
520
- function factLinkLabel(path) {
521
- if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
522
- return "Inspect result";
523
- if (/^\/t\/[^?#]+#merge$/.test(path))
524
- return "Merge";
525
- if (/^\/chat\?task=[^&]+&result=/.test(path))
526
- return "Open result";
527
- if (/^\/(?:chat\?task=|t\/)/.test(path))
528
- return "Open task";
529
- if (/^\/d\//.test(path))
530
- return "Open decision";
531
- return "Open console";
582
+ /** A fact's own next-action button row under the trusted origin read now; none without one. */
583
+ function factButton(phoneOrigin, link) {
584
+ try {
585
+ return phoneLinkButton(phoneOrigin?.() ?? null, { label: factLinkLabel(link), path: link });
586
+ }
587
+ catch {
588
+ return null;
589
+ }
590
+ }
591
+ /** A view's buttons on one row: its link, then any others ([Merge] [Look first]); none without a trusted origin. */
592
+ function viewKeyboard(phoneOrigin, view) {
593
+ try {
594
+ const origin = phoneOrigin?.() ?? null;
595
+ const row = [view.link, ...(view.also ?? [])].flatMap(link => phoneLinkButton(origin, link) ?? []);
596
+ return row.length === 0 ? [] : [row];
597
+ }
598
+ catch {
599
+ return [];
600
+ }
532
601
  }
533
602
  /** Team-conversation traffic to the chats that follow one — after the outbox, under the same delivery switch, never a problem to raise when nothing follows anything. */
534
603
  async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
@@ -720,7 +789,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
720
789
  const messageId = store.telegramProgressMessage(binding, progressRun);
721
790
  if (messageId === null && onlyExisting)
722
791
  return null;
723
- const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot);
792
+ const card = telegramProgressCard(store, store.getRun(progressRun.id), row.taskId, row.project, clock(), evidenceRoot, binding.approver);
724
793
  let button = null;
725
794
  try {
726
795
  button = phoneLinkButton(phoneOrigin?.() ?? null, card.link);
@@ -750,7 +819,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
750
819
  if (problem !== null)
751
820
  return { ok: false, error: problem };
752
821
  const card = store.chatCardFor(fact.destination, fact.taskRef, store.getTask(fact.taskId)?.createdAt ?? fact.createdAt, clock());
753
- const view = quietCardView(store, card.tasks, clock(), evidenceRoot);
822
+ const view = quietCardView(store, card.tasks, clock(), evidenceRoot, binding.approver);
754
823
  if (view === null)
755
824
  return { ok: true, messageId: card.message };
756
825
  let button = null;
@@ -792,14 +861,55 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
792
861
  if (!needsPerson(fact))
793
862
  return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
794
863
  const readyRun = fact.kind === "run-finished" ? store.telegramProgressRun(fact) : null;
795
- if (readyRun !== null) {
796
- const ping = readyPingView(store, store.getRun(readyRun.id), fact.taskId, fact.project, clock(), evidenceRoot);
797
- let button = null;
798
- try {
799
- button = phoneLinkButton(phoneOrigin?.() ?? null, ping.link);
864
+ if (readyRun !== null || (fact.kind !== "run-finished" && joinsBatch(fact))) {
865
+ // Any update for this person joins their open batch: one message, edited in place while it grows.
866
+ const batch = store.chatBatchFor(fact.destination, fact.taskRef, readyRun?.id ?? store.telegramProgressRun(fact)?.id ?? null, clock(), BATCH_MS, readyRun !== null ? null : fact.id);
867
+ // A lone update that is not finished work goes out exactly as it always has; a later one edits it in place.
868
+ const lone = batch.items.length === 1 && batch.items[0].notification !== null;
869
+ if (batch.message === null && lone) {
870
+ const sent = await deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
871
+ const placed = store.telegramMessageOf(fact.id, fact.destination);
872
+ if (placed !== null)
873
+ store.setChatBatchMessage(batch.id, placed, "");
874
+ return sent;
875
+ }
876
+ const view = finishedView(store, batch, clock(), evidenceRoot, binding.approver);
877
+ // Nothing in the batch reads as a line. Finished work left out on purpose (a release check, a replaced task,
878
+ // the reader's own completion) stays quiet; any other update that needs this person still reaches them alone.
879
+ if (view === null) {
880
+ if (readyRun !== null)
881
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
882
+ return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
800
883
  }
801
- catch { /* No trusted origin. */ }
802
- const sent = await sender(ping.text, button === null ? undefined : [button], [fact], ping.entities);
884
+ // While a lone update is still the only item, its edit keeps that update's own action button.
885
+ const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
886
+ const keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
887
+ const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
888
+ if (batch.message !== null) {
889
+ if (batch.digest === shown) {
890
+ store.recordTelegramMessage(fact, binding, batch.message, clock());
891
+ return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
892
+ }
893
+ const problem = (await readAccess()) ?? fence();
894
+ if (problem !== null)
895
+ return { ok: false, error: problem };
896
+ const edited = await editProgress(transport, binding.chatId, batch.message, view.text, keyboard);
897
+ if (edited.ok) {
898
+ store.setChatBatchMessage(batch.id, batch.message, shown);
899
+ store.recordTelegramMessage(fact, binding, batch.message, clock());
900
+ const after = (await readAccess()) ?? fence();
901
+ return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
902
+ }
903
+ if (edited.retryAfter !== undefined)
904
+ store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
905
+ // Only Telegram's definitive missing/uneditable answer permits a new message.
906
+ if (!edited.replace)
907
+ return edited;
908
+ }
909
+ const sent = await sender(view.text, keyboard.length === 0 ? undefined : keyboard, [fact]);
910
+ const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
911
+ if (placed !== null)
912
+ store.setChatBatchMessage(batch.id, placed, shown);
803
913
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
804
914
  }
805
915
  return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
@@ -815,7 +925,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
815
925
  const updated = progress ? await updateProgress(false) : null;
816
926
  outcome = updated !== null ? updated.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, updated.messageId) } : updated
817
927
  : rows[0].kind === "acceptance-evidence" ? await imageSender(rows[0]) : batched
818
- ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock)
928
+ ? await deliverDigest(botId, binding, sender, rows, digest.lastSentAt, clock, taskId => chatTitle(store, taskId))
819
929
  : await deliverOne(store, botId, binding, sender, rows[0], clock, phoneOrigin, evidenceRoot, projects);
820
930
  }
821
931
  const finalProblem = outcome.ok ? (await readAccess()) ?? fence() : null;
@@ -850,26 +960,29 @@ function isUrgent(notification) {
850
960
  /** The digest text: a header with the count and the window, then one
851
961
  * fact per entry — its subject, then its body's first line, indented.
852
962
  * Plain text, no buttons: nothing in a digest is tappable. */
853
- export function digestText(rows, since, now) {
963
+ export function digestText(rows, since, now, titleOf) {
854
964
  const window = since === null ? "" : ` since ${since.slice(0, 16).replace("T", " ")}`;
855
965
  const lines = [`digest — ${rows.length} routine fact(s)${window} (as of ${now.toISOString().slice(0, 16).replace("T", " ")})`, ""];
856
966
  for (const row of rows) {
857
- lines.push(digestEntry(row));
967
+ lines.push(digestEntry(row, titleOf));
858
968
  }
859
969
  return lines.join("\n");
860
970
  }
861
- function digestEntry(row) {
971
+ function digestEntry(row, titleOf) {
972
+ const title = row.taskId === null ? undefined : titleOf?.(row.taskId);
973
+ const task = row.taskId === null ? [] : [{ id: row.taskId, ...(title === undefined ? {} : { title }) }];
974
+ const subject = chatText(row.subject, task);
862
975
  const first = row.body.split("\n").map(one => one.trim()).find(one => one !== "");
863
976
  const body = first === undefined ? "" : `\n ${first.length > 200 ? `${first.slice(0, 200).replace(/[\uD800-\uDBFF]$/, "")}…` : first}`;
864
- return `• ${notificationIdentity(row)}${row.subject}${body}`;
977
+ return `• ${notificationIdentity(row, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}${chatText(body, task)}`;
865
978
  }
866
- async function deliverDigest(botId, binding, sender, rows, since, clock) {
867
- const text = digestText(rows, since, clock());
979
+ async function deliverDigest(botId, binding, sender, rows, since, clock, titleOf) {
980
+ const text = digestText(rows, since, clock(), titleOf);
868
981
  // Track the exact rows represented by each text part. A split row may bind
869
982
  // several messages; a digest message may bind several rows.
870
983
  let offset = text.indexOf("\n\n") + 2;
871
984
  const spans = rows.map(row => {
872
- const length = digestEntry(row).length;
985
+ const length = digestEntry(row, titleOf).length;
873
986
  const span = { row, start: offset, end: offset + length };
874
987
  offset += length + 1;
875
988
  return span;
@@ -917,16 +1030,13 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
917
1030
  else
918
1031
  body = `${packet.text}\n\n${notification.body}`;
919
1032
  }
920
- const parts = split(`${notificationIdentity(notification)}${alreadyAccepted ? "Acceptance recorded" : notification.subject}\n\n${body}`);
921
- let button = null;
922
- if (notification.link !== null && !alreadyAccepted && current) {
923
- try {
924
- button = phoneLinkButton(phoneOrigin?.() ?? null, { label: factLinkLabel(notification.link), path: notification.link });
925
- }
926
- catch {
927
- button = null;
928
- }
929
- }
1033
+ // A short title, never the task's id or a "— revision" suffix (chat-voice.ts).
1034
+ const title = notification.taskId === null ? undefined : chatTitle(store, notification.taskId);
1035
+ const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
1036
+ const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : notification.subject}${body === "" ? "" : `\n\n${body}`}`, task);
1037
+ // The task's title once: in front, unless the words already name it.
1038
+ const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1039
+ const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
930
1040
  // A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
931
1041
  const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
932
1042
  const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
@@ -1403,6 +1513,8 @@ function applyMessage(context, update, effects) {
1403
1513
  text: `paired: this chat now answers as ${approver}\n\nSend /status to check recent work, /task <id> for one task, or /help for your options.`,
1404
1514
  link_preview_options: { is_disabled: true },
1405
1515
  });
1516
+ // The bot is Toolroll in every chat list from here on (best effort; pairing never waits on it).
1517
+ await nameTelegramBot(transport, "pairing");
1406
1518
  });
1407
1519
  }
1408
1520
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.9",
3
+ "version": "0.9.11",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"