toolroll 0.9.17 → 0.9.19

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
@@ -27,7 +27,8 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
27
27
  import { chmodSync, readFileSync, rmSync, writeFileSync } from "node:fs";
28
28
  import { join } from "node:path";
29
29
  import { validateNote } from "./decision.js";
30
- import { isLifecycleNotification, isTelegramProgressNotification, TELEGRAM_HOLD_REASONS } from "./store.js";
30
+ import { resultShotsFor } from "./result-shots.js";
31
+ import { isLifecycleNotification, isTelegramProgressNotification, RESULT_SHOTS_KIND, TELEGRAM_HOLD_REASONS } from "./store.js";
31
32
  import { telegramProgressCard } from "./telegram-progress.js";
32
33
  import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerson, quietCardView } from "./chat-quiet.js";
33
34
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
@@ -36,6 +37,8 @@ import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team
36
37
  import { applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowDecisionAt } from "./telegram-flow.js";
37
38
  import { connectChannel, FLOW_WORDS, takeChannelMessage, watchedChannel } from "./chat-inbox.js";
38
39
  import { focusContextFor, taskInCeiling } from "./chat-channel.js";
40
+ import { answersPrompt, applyDecideFeedback, applyDecideTap, decideFallbackLink, dropDecideTokens, hasLiveDecideTokens, linksFor, placeDecidePrompt, decideOffer, decideTargetOf, isDecideToken, mergedText, mintDecideButtons, offerFingerprint, openPromptFor, placeDecideTokens, recordChatMerge, retireDecideTokens } from "./chat-decide.js";
41
+ import { mergePullRequest } from "./pull-request-flow.js";
39
42
  import { phoneCommand, phoneStatus, phoneTaskView, PHONE_CONSOLE_FOOTER, PHONE_HELP, notificationIdentity, phoneTaskChoices, resolvePhoneTask, phoneFocusText, phoneTaskListText, phoneText, PHONE_NO_MATCH, PHONE_BACK_TO_LEAD } from "./telegram-status.js";
40
43
  import { MATE_MESSAGE_MAX_CHARS } from "./mate.js";
41
44
  import { envValue } from "./names.js";
@@ -159,7 +162,7 @@ export function scrub(text, token) {
159
162
  return token === "" ? text : text.split(token).join(redactToken(token));
160
163
  }
161
164
  export function createTransport(token, timeoutMs = 30_000) {
162
- return async (method, params, signal, upload) => {
165
+ return async (method, params, signal, upload, more) => {
163
166
  const controller = new AbortController();
164
167
  const timer = setTimeout(() => controller.abort(), timeoutMs);
165
168
  const onAbort = () => controller.abort();
@@ -176,7 +179,7 @@ export function createTransport(token, timeoutMs = 30_000) {
176
179
  // the verified bytes as one Blob under the file name given.
177
180
  const request = upload === undefined
178
181
  ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(params), signal: controller.signal }
179
- : { method: "POST", body: multipartOf(params, upload), signal: controller.signal };
182
+ : { method: "POST", body: multipartOf(params, upload, ...(more ?? [])), signal: controller.signal };
180
183
  const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, request);
181
184
  const body = (await response.json());
182
185
  // A missing or malformed acknowledgement does not prove a send failed.
@@ -248,14 +251,15 @@ export function transportError(error) {
248
251
  return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
249
252
  }
250
253
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
251
- export function multipartOf(params, upload) {
254
+ export function multipartOf(params, upload, ...more) {
252
255
  const form = new FormData();
253
256
  for (const [key, value] of Object.entries(params)) {
254
257
  if (value === undefined || value === null)
255
258
  continue;
256
259
  form.append(key, typeof value === "object" ? JSON.stringify(value) : String(value));
257
260
  }
258
- form.append(upload.field, new Blob([new Uint8Array(upload.bytes)], { type: upload.contentType }), upload.fileName);
261
+ for (const one of [upload, ...more])
262
+ form.append(one.field, new Blob([new Uint8Array(one.bytes)], { type: one.contentType }), one.fileName);
259
263
  return form;
260
264
  }
261
265
  // ---- pairing ---------------------------------------------------------------
@@ -601,6 +605,29 @@ function viewKeyboard(phoneOrigin, view) {
601
605
  return [];
602
606
  }
603
607
  }
608
+ /** Where a paired person taps: their binding and private chat. */
609
+ function decideSeat(binding) {
610
+ return { channel: "telegram", binding: binding.id, chat: binding.chatId, approver: binding.approver, generation: binding.approverGeneration };
611
+ }
612
+ /** Decide buttons as Telegram draws them: an act's opaque token, or a link under the trusted origin read now (none without one). */
613
+ function decideKeyboard(phoneOrigin, rows) {
614
+ let origin = null;
615
+ try {
616
+ origin = phoneOrigin?.() ?? null;
617
+ }
618
+ catch {
619
+ origin = null;
620
+ }
621
+ return rows.map(row => row.flatMap((one) => "token" in one ? [{ text: one.label, callback_data: one.token }]
622
+ : phoneLinkButton(origin, one.link) ?? [])).filter(row => row.length > 0);
623
+ }
624
+ /** What this card may act on in the chat now, for this person under their current ceiling; null keeps its link. */
625
+ function decideOfferFor(store, binding, target, projects, now, root) {
626
+ if (target === null || target === undefined)
627
+ return null;
628
+ const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
629
+ return principal.ok ? decideOffer(store, target, principal.who, now, root) : null;
630
+ }
604
631
  /** 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. */
605
632
  async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
606
633
  if (store.listTelegramTeamChats(botId).length === 0)
@@ -631,7 +658,7 @@ async function deliverOutbox(store, botId, transport, owner, clock, report, read
631
658
  // Routine progress facts are not a problem to fix: a first pairing
632
659
  // starts from now and settles them as history. Anything else pending
633
660
  // is named, once per pass, as before.
634
- if (store.listNotifications("pending").some(row => !isLifecycleNotification(row)))
661
+ if (store.listNotifications("pending").some(row => !isLifecycleNotification(row) && row.kind !== RESULT_SHOTS_KIND))
635
662
  report.problems.push("outbox rows are pending but no chat is paired — `toolroll bridge telegram pair`");
636
663
  return;
637
664
  }
@@ -663,7 +690,7 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
663
690
  const groups = [];
664
691
  for (const row of claimed) {
665
692
  const last = groups[groups.length - 1];
666
- if (!quiet && digest.everyMs !== null && !isUrgent(row) && last !== undefined && !isUrgent(last[0]))
693
+ if (!quiet && digest.everyMs !== null && joinsDigest(row) && last !== undefined && joinsDigest(last[0]))
667
694
  last.push(row);
668
695
  else
669
696
  groups.push([row]);
@@ -760,18 +787,26 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
760
787
  const sent = await sender(`A screenshot for result #${row.run} could not be sent: ${verified.problem}. Inspect the result before accepting.`);
761
788
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
762
789
  }
790
+ // Already sent with its result (result-shots.ts): the same screenshot is not sent again.
791
+ const artifact = Number(match[2]);
792
+ if (store.resultShotsSent(row.destination, row.run).has(artifact))
793
+ return { ok: true, receipt: "skipped:screenshot-sent" };
794
+ // Marked while it goes, so the screenshots sent with the result skip it; any failure unmarks it for the retry.
795
+ store.markResultShotsSent(row.destination, row.run, [artifact], clock());
763
796
  // No await between the access fence, file validation and upload.
764
797
  let answer;
765
798
  try {
766
799
  answer = await transport("sendDocument", { chat_id: binding.chatId, caption: `${resultTaskLabel(row.taskId)} · result #${row.run} · screenshot for acceptance` }, undefined, {
767
- field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, Number(match[2]), verified.format),
800
+ field: "document", bytes: verified.bytes, contentType: verified.format === "png" ? "image/png" : "image/jpeg", fileName: resultImageFileName(row.taskId, row.run, artifact, verified.format),
768
801
  });
769
802
  }
770
803
  catch {
804
+ store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
771
805
  return { ok: false, error: "Screenshot delivery is unconfirmed; retry may duplicate it" };
772
806
  }
773
807
  const messageId = answer.result?.message_id;
774
808
  if (!answer.ok || !Number.isSafeInteger(messageId)) {
809
+ store.unmarkResultShotsSent(row.destination, row.run, [artifact]);
775
810
  if (answer.parameters?.retry_after !== undefined)
776
811
  store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
777
812
  return { ok: false, error: answer.uncertain || (answer.ok && messageId == null) ? "Screenshot delivery is unconfirmed; retry may duplicate it" : "Telegram did not accept the screenshot" };
@@ -780,7 +815,56 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
780
815
  const after = (await readAccess()) ?? fence();
781
816
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, String(messageId)) } : { ok: false, error: after };
782
817
  };
783
- const batched = !quiet && digest.everyMs !== null && !isUrgent(rows[0]);
818
+ /** Screenshots with a result (result-shots.ts): photos, several as one album, re-proved before every call. Each is
819
+ * marked sent before it goes, so an unconfirmed send is never repeated; only Telegram's outright refusal unmarks it. */
820
+ const shotsSender = async (row) => {
821
+ let receipt = null;
822
+ for (let call = 0; call <= 4; call++) {
823
+ const problem = (await readAccess()) ?? fence();
824
+ if (problem !== null)
825
+ return { ok: false, error: problem };
826
+ const plan = resultShotsFor(store, evidenceRoot, telegramConversationRepos(store, binding.approver, projects), row, store.resultShotsSent(row.destination, row.run ?? 0));
827
+ if (plan.kind === "none")
828
+ return call > 0 ? { ok: true, receipt } : { ok: true, receipt: `skipped:screenshots-${plan.why}` };
829
+ if (plan.kind === "line") {
830
+ const sent = await sender(plan.text, undefined, [row]);
831
+ return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
832
+ }
833
+ // No await between the access fence, the files' verification and the upload.
834
+ const photos = plan.shots.filter(one => one.photo);
835
+ const batch = photos.length >= 2 ? photos : [plan.shots[0]];
836
+ const upload = (one, field) => ({ field, fileName: one.fileName, contentType: one.format === "png" ? "image/png" : "image/jpeg", bytes: one.bytes });
837
+ const ids = batch.map(one => one.artifact);
838
+ store.markResultShotsSent(row.destination, plan.run, ids, clock());
839
+ let answer;
840
+ try {
841
+ answer = batch.length > 1
842
+ ? await transport("sendMediaGroup", { chat_id: binding.chatId, media: batch.map((one, index) => ({ type: "photo", media: `attach://shot${index}`, ...(index === 0 ? { caption: one.caption } : {}) })) }, undefined, upload(batch[0], "shot0"), batch.slice(1).map((one, index) => upload(one, `shot${index + 1}`)))
843
+ : batch[0].photo
844
+ ? await transport("sendPhoto", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "photo"))
845
+ : await transport("sendDocument", { chat_id: binding.chatId, caption: batch[0].caption }, undefined, upload(batch[0], "document"));
846
+ }
847
+ catch {
848
+ answer = { ok: false, uncertain: true };
849
+ }
850
+ if (!answer.ok && answer.uncertain !== true) {
851
+ store.unmarkResultShotsSent(row.destination, plan.run, ids);
852
+ if (answer.parameters?.retry_after !== undefined)
853
+ store.deferTelegram(botId, new Date(clock().getTime() + answer.parameters.retry_after * 1000).toISOString());
854
+ return { ok: false, error: "Telegram did not accept the screenshots" };
855
+ }
856
+ const messages = (Array.isArray(answer.result) ? answer.result : [answer.result])
857
+ .map(one => one?.message_id).filter((one) => Number.isSafeInteger(one));
858
+ // An unconfirmed send stays marked: it may have arrived, so it is never sent again.
859
+ if (!answer.ok || messages.length === 0)
860
+ report.problems.push(`notification ${row.id}: Telegram did not confirm the screenshots; they are not sent again`);
861
+ for (const one of messages)
862
+ store.recordTelegramMessage(row, binding, String(one), clock());
863
+ receipt = receiptFor(botId, binding.chatId, messages.length === 0 ? null : String(messages[0]));
864
+ }
865
+ return { ok: true, receipt };
866
+ };
867
+ const batched = !quiet && digest.everyMs !== null && joinsDigest(rows[0]);
784
868
  const row = rows[0];
785
869
  const progressRun = batched ? null : store.telegramProgressRun(row);
786
870
  const progress = progressRun !== null && isTelegramProgressNotification(row);
@@ -885,35 +969,65 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
885
969
  return { ok: true, receipt: receiptFor(botId, binding.chatId, card.messageId) };
886
970
  return deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
887
971
  }
972
+ // A message about one result, plan, failure or pull request acts in place (chat-decide.ts); its buttons are
973
+ // minted only when what it offers changed, or its own were spent (Not now), so an unchanged repaint keeps the
974
+ // ones already in the chat.
975
+ const offer = decideOfferFor(store, binding, view.target, projects, clock(), evidenceRoot);
976
+ const text = offer?.text ?? view.text;
888
977
  // While a lone update is still the only item, its edit keeps that update's own action button.
889
- const own = lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
890
- const keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
891
- const shown = createHash("sha256").update(JSON.stringify([view.text, keyboard])).digest("hex");
978
+ const own = offer === null && lone && fact.link !== null ? factButton(phoneOrigin, fact.link) : null;
979
+ let keyboard = own !== null ? [own] : viewKeyboard(phoneOrigin, view);
980
+ const shown = createHash("sha256").update(JSON.stringify(offer === null ? [view.text, keyboard] : [text, offerFingerprint(offer)])).digest("hex");
981
+ let tokens = [];
982
+ // Minted unplaced: they ride a message only once it shows them, and until then the old card's buttons work.
983
+ const mintFor = () => {
984
+ if (offer === null)
985
+ return;
986
+ const minted = mintDecideButtons(store, decideSeat(binding), view.target, offer, clock());
987
+ keyboard = decideKeyboard(phoneOrigin, minted.rows);
988
+ tokens = minted.tokens;
989
+ };
892
990
  if (batch.message !== null) {
893
- if (batch.digest === shown) {
991
+ if (batch.digest === shown && (offer === null || hasLiveDecideTokens(store, "telegram", binding.chatId, batch.message, clock()))) {
894
992
  store.recordTelegramMessage(fact, binding, batch.message, clock());
895
993
  return { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) };
896
994
  }
897
995
  const problem = (await readAccess()) ?? fence();
898
996
  if (problem !== null)
899
997
  return { ok: false, error: problem };
900
- const edited = await editProgress(transport, binding.chatId, batch.message, view.text, keyboard);
998
+ mintFor();
999
+ const edited = await editProgress(transport, binding.chatId, batch.message, text, keyboard);
901
1000
  if (edited.ok) {
1001
+ // The repaint landed: the old words' buttons retire and the new ones ride the message.
1002
+ retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
1003
+ placeDecideTokens(store, tokens, batch.message);
902
1004
  store.setChatBatchMessage(batch.id, batch.message, shown);
903
1005
  store.recordTelegramMessage(fact, binding, batch.message, clock());
904
1006
  const after = (await readAccess()) ?? fence();
905
1007
  return after === null ? { ok: true, receipt: receiptFor(botId, binding.chatId, batch.message) } : { ok: false, error: after };
906
1008
  }
1009
+ // A failed edit: the buttons it carried never act, and the card's old ones keep working.
1010
+ dropDecideTokens(store, tokens, clock());
907
1011
  if (edited.retryAfter !== undefined)
908
1012
  store.deferTelegram(botId, new Date(clock().getTime() + edited.retryAfter * 1000).toISOString());
909
1013
  // Only Telegram's definitive missing/uneditable answer permits a new message.
910
1014
  if (!edited.replace)
911
1015
  return edited;
912
1016
  }
913
- const sent = await sender(view.text, keyboard.length === 0 ? undefined : keyboard, [fact]);
1017
+ // A new message: its buttons are minted for it, never the ones a failed edit was given.
1018
+ mintFor();
1019
+ const sent = await sender(text, keyboard.length === 0 ? undefined : keyboard, [fact]);
914
1020
  const placed = sent.ok ? sent.messageId : store.telegramMessageOf(fact.id, fact.destination);
915
1021
  if (placed !== null)
916
1022
  store.setChatBatchMessage(batch.id, placed, shown);
1023
+ if (sent.ok && sent.messageId !== null) {
1024
+ // The new card replaces the old one: only its buttons act.
1025
+ if (batch.message !== null)
1026
+ retireDecideTokens(store, "telegram", binding.chatId, batch.message, clock());
1027
+ placeDecideTokens(store, tokens, sent.messageId);
1028
+ }
1029
+ else
1030
+ dropDecideTokens(store, tokens, clock());
917
1031
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
918
1032
  }
919
1033
  return fact.kind === "acceptance-evidence" ? imageSender(fact) : deliverOne(store, botId, binding, sender, fact, clock, phoneOrigin, evidenceRoot, projects);
@@ -944,7 +1058,9 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
944
1058
  return sent.ok ? { ok: true, receipt: receiptFor(botId, binding.chatId, sent.messageId) } : sent;
945
1059
  };
946
1060
  let outcome;
947
- if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
1061
+ if (rows.length === 1 && row.kind === RESULT_SHOTS_KIND)
1062
+ outcome = await shotsSender(row);
1063
+ else if (rows.length === 1 && row.kind === LEAD_SAY_KIND)
948
1064
  outcome = await leadSayOutcome(row);
949
1065
  else if (quiet && rows.length === 1 && isTaskFact(row))
950
1066
  outcome = await quietOutcome(row);
@@ -988,6 +1104,11 @@ async function deliverOutboxTo(store, botId, binding, transport, owner, clock, r
988
1104
  function isUrgent(notification) {
989
1105
  return /^decision:\d+$/.test(notification.dedupeKey) || notification.pushClass === "attention" || notification.kind === "acceptance-evidence" || notification.kind === "acceptance-ready";
990
1106
  }
1107
+ /** A routine fact the digest may carry. Screenshots are never urgent (they follow their result message, which the
1108
+ * task's order fence holds them behind) and never a digest line: they go as their own photos once it has gone. */
1109
+ function joinsDigest(notification) {
1110
+ return !isUrgent(notification) && notification.kind !== RESULT_SHOTS_KIND;
1111
+ }
991
1112
  /** The digest text: a header with the count and the window, then one
992
1113
  * fact per entry — its subject, then its body's first line, indented.
993
1114
  * Plain text, no buttons: nothing in a digest is tappable. */
@@ -1066,8 +1187,19 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1066
1187
  const task = notification.taskId === null ? [] : [{ id: notification.taskId, ...(title === undefined ? {} : { title }) }];
1067
1188
  const words = chatText(`${alreadyAccepted ? "Acceptance recorded" : leadSubjectOf(store, notification, binding.approver)}${body === "" ? "" : `\n\n${body}`}`, task);
1068
1189
  // The task's title once: in front, unless the words already name it.
1069
- const parts = split(`${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1070
- const button = notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
1190
+ // A result, plan, failure or ready pull request acts in place (chat-decide.ts); otherwise its one link.
1191
+ const target = alreadyAccepted || !current ? null : decideTargetOf(notification);
1192
+ const offer = decideOfferFor(store, binding, target, projects, clock(), evidenceRoot);
1193
+ const decided = offer === null ? null : mintDecideButtons(store, decideSeat(binding), target, offer, clock());
1194
+ const parts = split(offer?.text ?? `${notificationIdentity(notification, title !== undefined && mentions(words, title) ? undefined : title)}${words}`);
1195
+ const fallback = offer === null ? decideFallbackLink(target) : null;
1196
+ const button = fallback !== null ? (() => { try {
1197
+ return phoneLinkButton(phoneOrigin?.() ?? null, fallback);
1198
+ }
1199
+ catch {
1200
+ return null;
1201
+ } })()
1202
+ : offer === null && notification.link !== null && !alreadyAccepted && current ? factButton(phoneOrigin, notification.link) : null;
1071
1203
  // A flow card waiting on a decision (v86): Approve, Edit, Send back on the last part, for this visit only.
1072
1204
  const visit = FLOW_DECIDE_KEY.exec(notification.dedupeKey);
1073
1205
  const waiting = visit === null ? null : flowDecisionAt(store, Number(visit[1]), Number(visit[2]));
@@ -1079,16 +1211,22 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1079
1211
  let last = null;
1080
1212
  for (const [index, part] of parts.entries()) {
1081
1213
  const final = index === parts.length - 1;
1082
- const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : button !== null ? [button] : undefined;
1214
+ const decideKeys = decided === null ? [] : decideKeyboard(phoneOrigin, decided.rows);
1215
+ const keyboard = !final ? undefined : keys !== null ? [...keys.keyboard, ...(button === null ? [] : [button])] : decideKeys.length > 0 ? decideKeys : button !== null ? [button] : undefined;
1083
1216
  const sent = await sender(part, keyboard);
1084
- if (!sent.ok)
1217
+ if (!sent.ok) {
1218
+ if (decided !== null)
1219
+ dropDecideTokens(store, decided.tokens, clock());
1085
1220
  return { ok: false, error: sent.error };
1221
+ }
1086
1222
  last = sent.messageId;
1087
1223
  }
1088
1224
  if (flowKeys !== null && last !== null)
1089
1225
  store.placeTelegramFlowActions(flowKeys.tokens, last);
1090
1226
  if (questionKeys !== null && last !== null)
1091
1227
  store.placeTelegramQuestionActions(questionKeys.tokens, last);
1228
+ if (decided !== null && last !== null)
1229
+ placeDecideTokens(store, decided.tokens, last);
1092
1230
  return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
1093
1231
  }
1094
1232
  // A decision. Every safety-bearing word goes out before anything tappable
@@ -1335,13 +1473,15 @@ async function projectsForTap(context, update) {
1335
1473
  const callback = update.callback_query;
1336
1474
  // Flow decision buttons (v86) work with or without the lead's conversation on this phone.
1337
1475
  const flowTap = callback !== undefined && context.store.getTelegramFlowAction(callback.data ?? "") !== null;
1338
- if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap))
1476
+ // So do a result's, plan's or pull request's own buttons (chat-decide.ts).
1477
+ const decideTap = callback !== undefined && isDecideToken(context.store, callback.data ?? "");
1478
+ if (callback === undefined || context.readProjects === undefined || (context.conversation === undefined && !flowTap && !decideTap))
1339
1479
  return null;
1340
1480
  const binding = callback.from === undefined ? null : context.store.liveTelegramBindingFor(context.botId, String(callback.from.id));
1341
1481
  if (binding === null || callback.from === undefined ||
1342
1482
  callback.message?.chat === undefined ||
1343
1483
  // Proposal cards and task picks read the chat's project ceiling.
1344
- (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !(callback.data ?? "").startsWith("pick:")))
1484
+ (context.store.getTelegramProposalAction(callback.data ?? "") === null && context.store.getTelegramFlowAction(callback.data ?? "") === null && !decideTap && !(callback.data ?? "").startsWith("pick:")))
1345
1485
  return null;
1346
1486
  const chat = callback.message.chat;
1347
1487
  const chatId = String(chat.id);
@@ -1482,6 +1622,26 @@ function applyMessage(context, update, effects) {
1482
1622
  }
1483
1623
  return;
1484
1624
  }
1625
+ // After Request changes asked "What should change?": the person's next message in their own chat is the feedback.
1626
+ // A reply to some other message (a decision, a result) keeps its own meaning.
1627
+ const decidePrompt = binding !== null && chat.type === "private" && String(chat.id) === binding.chatId && typeof message.text === "string" && message.text.trim() !== "" && !message.text.startsWith("/")
1628
+ ? openPromptFor(store, { channel: "telegram", binding: binding.id, chat: binding.chatId }, clock()) : null;
1629
+ if (binding !== null && decidePrompt !== null && answersPrompt(decidePrompt, message.reply_to_message === undefined ? null : String(message.reply_to_message.message_id))) {
1630
+ const repos = context.projects === null ? [] : telegramConversationRepos(store, binding.approver, context.projects);
1631
+ const answer = applyDecideFeedback(store, decideSeat(binding), decidePrompt, message.text ?? "", repos, context.conversation?.evidenceRoot, clock());
1632
+ effects.push(async () => {
1633
+ let button = null;
1634
+ try {
1635
+ button = phoneLinkButton(context.conversation?.phoneOrigin?.() ?? null, answer.link);
1636
+ }
1637
+ catch {
1638
+ button = null;
1639
+ }
1640
+ await transport("sendMessage", { chat_id: binding.chatId, text: answer.said, link_preview_options: { is_disabled: true }, reply_parameters: { message_id: message.message_id },
1641
+ ...(button === null ? {} : { reply_markup: { inline_keyboard: [button] } }) });
1642
+ });
1643
+ return;
1644
+ }
1485
1645
  // A message to a teammate by name (v96), in the person's own chat: a card on its desk, and the answer comes back here.
1486
1646
  if (binding !== null && message.reply_to_message === undefined && message.chat?.type === "private" && String(chat.id) === binding.chatId && store.accountOf(binding.approver)?.role === "approver") {
1487
1647
  const repos = context.projects === null ? store.knownRepos().filter(repo => store.accountCanAccess(binding.approver, repo)) : telegramConversationRepos(store, binding.approver, context.projects);
@@ -1881,6 +2041,14 @@ function applyCallback(context, update, effects) {
1881
2041
  const binding = from === undefined ? null : store.liveTelegramBindingFor(botId, String(from.id));
1882
2042
  const tapChat = message?.chat === undefined ? null : String(message.chat.id);
1883
2043
  const followedGroup = tapChat !== null && binding !== null && tapChat !== binding.chatId && store.telegramTeamChat(botId, tapChat)?.kind === "group";
2044
+ // A result's, plan's, failure's or pull request's own buttons act only in the paired person's own chat; a tap
2045
+ // anywhere else is still answered, so the button never just spins.
2046
+ if (isDecideToken(store, token) && (binding === null || tapChat === null || tapChat !== binding.chatId)) {
2047
+ report.ignored++;
2048
+ ack(binding === null ? "These buttons work only for the person they were sent to. Nothing was done."
2049
+ : "These buttons work only in your own chat with the bot. Nothing was done.");
2050
+ return;
2051
+ }
1884
2052
  if (binding === null ||
1885
2053
  from === undefined ||
1886
2054
  message === undefined ||
@@ -2010,6 +2178,56 @@ function applyCallback(context, update, effects) {
2010
2178
  editText(`${message.text ?? ask.question}\n\nYou chose: ${option}`.slice(0, 4000));
2011
2179
  return;
2012
2180
  }
2181
+ // A result's, plan's, failure's or pull request's own buttons (chat-decide.ts): in the person's own chat only.
2182
+ if (isDecideToken(store, token)) {
2183
+ const repos = context.projects === null ? null : telegramConversationRepos(store, binding.approver, context.projects);
2184
+ if (repos === null) {
2185
+ ack("Your projects couldn't be read just now. Try again.");
2186
+ return;
2187
+ }
2188
+ const messageId = String(message.message_id);
2189
+ const tapped = applyDecideTap(store, decideSeat(binding), { token, message: messageId, shown: message.text ?? "", repos, ...(context.conversation === undefined ? {} : { root: context.conversation.evidenceRoot }), now: clock() });
2190
+ if (tapped === null) {
2191
+ report.ignored++;
2192
+ ack("That button doesn't do anything now.");
2193
+ return;
2194
+ }
2195
+ if (tapped.ignored === true)
2196
+ report.ignored++;
2197
+ ack(tapped.ack.slice(0, 190));
2198
+ if (tapped.edit !== undefined)
2199
+ editText(tapped.edit.text.slice(0, 4000), decideKeyboard(context.conversation?.phoneOrigin, tapped.edit.rows));
2200
+ if (tapped.prompt !== undefined) {
2201
+ const prompt = tapped.prompt;
2202
+ effects.push(async () => {
2203
+ const answer = await transport("sendMessage", { chat_id: binding.chatId, text: prompt.text, link_preview_options: { is_disabled: true },
2204
+ reply_parameters: { message_id: message.message_id }, reply_markup: { force_reply: true, input_field_placeholder: "What should change?" } });
2205
+ const id = answer.result?.message_id;
2206
+ if (answer.ok && Number.isSafeInteger(id))
2207
+ placeDecidePrompt(store, prompt.id, String(id));
2208
+ });
2209
+ }
2210
+ if (tapped.merge !== undefined) {
2211
+ const merge = tapped.merge, shown = tapped.edit?.text ?? message.text ?? "";
2212
+ // GitHub is a network call: after the tap's transaction, then the card says how it went.
2213
+ effects.push(async () => {
2214
+ let merged;
2215
+ try {
2216
+ merged = context.conversation?.merge !== undefined ? await context.conversation.merge({ runId: merge.runId, by: merge.by })
2217
+ : await mergePullRequest(store, { runId: merge.runId, by: merge.by, clock });
2218
+ }
2219
+ catch {
2220
+ merged = { ok: false, message: "GitHub couldn't be reached just now. Try again from the task." };
2221
+ }
2222
+ // The ledger says what happened, failure included, once GitHub has answered.
2223
+ recordChatMerge(store, merge, merged.ok ? { ok: true } : { ok: false, message: merged.message }, clock());
2224
+ // A merge that didn't land keeps a way to the task.
2225
+ await transport("editMessageText", { chat_id: binding.chatId, message_id: message.message_id, text: mergedText(shown, merged.ok ? { ok: true } : { ok: false, message: merged.message }).slice(0, 4000),
2226
+ link_preview_options: { is_disabled: true }, reply_markup: { inline_keyboard: merged.ok ? [] : decideKeyboard(context.conversation?.phoneOrigin, linksFor({ kind: "merge", taskId: merge.taskId, run: merge.runId })) } });
2227
+ });
2228
+ }
2229
+ return;
2230
+ }
2013
2231
  const action = store.getTelegramAction(token);
2014
2232
  if (action === null && context.conversation !== undefined && store.getTelegramProposalAction(token) !== null) {
2015
2233
  // A proposal card's button: the shared confirm door, inside this
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.17",
3
+ "version": "0.9.19",
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"