toolroll 0.9.37 → 0.9.39

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.
@@ -24,7 +24,7 @@ import { LIMITS } from "./decision.js";
24
24
  import { keptDraft } from "./flow-draft.js";
25
25
  import { decideFlowCard, draftFor, flowDefinitionOf } from "./flow-engine.js";
26
26
  import { chooseFlowCard, flowChoiceAt, flowSendPaths, readFlowSend } from "./flow-send.js";
27
- import { deciderOf } from "./flows.js";
27
+ import { deciderOf, FLOW_END, replyTarget } from "./flows.js";
28
28
  import { phoneLinkButton } from "./telegram-mate.js";
29
29
  import { telegramButton } from "./contracts/telegram-callback.js";
30
30
  export const FLOW_DECIDE_KEY = /^flow-decide:([1-9][0-9]{0,14}):([1-9][0-9]{0,9})$/;
@@ -41,6 +41,29 @@ export function flowDecisionAt(store, cardId, entry) {
41
41
  return null;
42
42
  return { card, flow, stage, draft: draftFor(definition, stage) };
43
43
  }
44
+ /** Where Approve and Send back take the card, on one line. */
45
+ function decisionTerms(waiting) {
46
+ const approve = waiting.stage.next === null ? "Approve → done" : `Approve → ${titleOf(waiting, waiting.stage.next)}`;
47
+ return [approve, ...(waiting.stage.onFail === null ? [] : [`Send back → ${titleOf(waiting, waiting.stage.onFail)}, with your note`])].join(" · ");
48
+ }
49
+ /**
50
+ * A flow decision as Telegram shows it: the action and where each button takes the card first, then the draft (as
51
+ * the card holds it now, so what is approved is what is read) and why a teammate handed it over, then the flow and
52
+ * zone. `said` is the saved notice's body: the words before its instruction are the teammate's handoff.
53
+ */
54
+ export function flowDecisionText(waiting, said, head = `Approve “${waiting.card.title}”`) {
55
+ const at = said === null ? -1 : said.indexOf(`${waiting.stage.title}: approve`);
56
+ const handoff = at > 0 ? said.slice(0, at).trim() : "";
57
+ const draft = waiting.draft === null ? undefined : waiting.card.outputs[waiting.draft.id]?.trim();
58
+ return [
59
+ head,
60
+ decisionTerms(waiting),
61
+ ...(handoff === "" ? [] : ["", handoff]),
62
+ ...(draft === undefined || draft === "" ? [] : ["", "Draft:", draft]),
63
+ "",
64
+ `${waiting.flow.name} · ${waiting.stage.title}`,
65
+ ].join("\n");
66
+ }
44
67
  /** Buttons for one visit, minted before the send; `place` stamps the message they landed on. */
45
68
  export function flowButtons(store, binding, waiting, now) {
46
69
  const mint = (action) => ({ action, token: randomBytes(16).toString("hex") });
@@ -64,6 +87,17 @@ export function flowSendKeyboardRow(origin, content) {
64
87
  const pull = content.links.flatMap(one => "url" in one ? [{ text: one.label, url: one.url }] : []);
65
88
  return [...pages.slice(0, 1), ...pull, ...pages.slice(1)].slice(0, 3);
66
89
  }
90
+ /** A choice as Telegram shows it, first: what to choose, then where each option (and a reply) takes the card. The
91
+ * options are the ones its buttons carry: offered when sent, and still offered. */
92
+ export function flowChoiceHead(visit, content) {
93
+ const titleIn = (id) => visit.definition.stages.find(one => one.id === id)?.title ?? id;
94
+ const options = (content.options ?? []).flatMap(one => {
95
+ const option = visit.stage.options?.[one.choice];
96
+ return option === undefined || option.label !== one.label ? [] : [`${one.label} → ${option.to === FLOW_END ? "closes the card" : titleIn(option.to)}`];
97
+ });
98
+ const reply = content.reply === true ? replyTarget(visit.stage) : null;
99
+ return [`Choose what happens to “${visit.card.title}”`, [...options, ...(reply === null ? [] : [`or reply → ${titleIn(reply)}, with your note`])].join(" · ")];
100
+ }
67
101
  /** A choice's buttons for one visit, one option to a row, minted before the send; `place` stamps the message they landed on. */
68
102
  export function flowChoiceButtons(store, binding, visit, content, now) {
69
103
  // Only the options the zone still offers as they were sent: a flow changed since sends no stale button.
@@ -88,7 +122,9 @@ export function applyFlowChoiceTap(store, binding, choice, message, repos, now)
88
122
  if (!chosen.ok)
89
123
  return [{ kind: "ack", text: chosen.message.slice(0, 190) }];
90
124
  store.retireFlowChoices(choice.card, choice.entry, now);
91
- return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ You chose “${choice.label}”. ${chosen.said}`.slice(0, 4000) }];
125
+ // The door's words name the option already ("Ship it. Moved to Ship."); an ending says what happened instead.
126
+ const said = chosen.said.startsWith(`${choice.label}.`) ? chosen.said : `You chose “${choice.label}”. ${chosen.said}`;
127
+ return [{ kind: "ack", text: choice.label.slice(0, 190) }, { kind: "edit", text: `${message.text}\n\n✅ ${said}`.slice(0, 4000) }];
92
128
  }
93
129
  /** A tapped flow button, applied inside the update's transaction. */
94
130
  export function applyFlowTap(store, binding, action, message, repos, now) {
@@ -107,7 +143,7 @@ export function applyFlowTap(store, binding, action, message, repos, now) {
107
143
  if (!decided.ok)
108
144
  return [{ kind: "ack", text: decided.message.slice(0, 190) }];
109
145
  store.retireTelegramFlowVisit(action.card, action.entry, now);
110
- return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${message.text}\n\n✅ You approved it. ${decided.said}`.slice(0, 4000) }];
146
+ return [{ kind: "ack", text: "Approved" }, { kind: "edit", text: `${message.text}\n\n✅ ${decided.said}`.slice(0, 4000) }];
111
147
  }
112
148
  const prompt = { chatId: binding.chatId, binding: binding.id, card: action.card, entry: action.entry, mode: action.action };
113
149
  if (action.action === "edit") {
@@ -157,8 +193,7 @@ export function applyFlowReply(store, binding, prompt, text, repos, now) {
157
193
  store.retireTelegramFlowVisit(prompt.card, prompt.entry, now);
158
194
  const fresh = flowDecisionAt(store, prompt.card, prompt.entry);
159
195
  const buttons = flowButtons(store, binding, fresh, now);
160
- return [{ kind: "decide", keyboard: buttons.keyboard, tokens: buttons.tokens,
161
- text: `${waiting.flow.name}: your version of the draft for “${waiting.card.title}”\n\n${kept}\n\nApprove to send it as written.` }];
196
+ return [{ kind: "decide", keyboard: buttons.keyboard, tokens: buttons.tokens, text: flowDecisionText(fresh, null, `Approve your version of “${waiting.card.title}”`) }];
162
197
  }
163
198
  // A note over its limit is refused with the limit, never cut.
164
199
  if (said.length > LIMITS.note)
@@ -256,7 +256,7 @@ export function applyProposalTap(store, binding, token, message, repos, options,
256
256
  return;
257
257
  }
258
258
  ack("irreversible — confirm it");
259
- edit(`⚠ This answer is IRREVERSIBLE.\n\n${body}\n\nConfirm?`, [
259
+ edit(`${body}\n\n⚠ Last step: this answer can't be undone. Confirm?`, [
260
260
  [telegramButton("⚠ Yes, answer it", yes)],
261
261
  [telegramButton("Cancel", cancel)],
262
262
  ]);
package/dist/telegram.js CHANGED
@@ -34,7 +34,7 @@ import { enqueueEveningDigests, finishedView, isTaskFact, joinsBatch, needsPerso
34
34
  import { BATCH_MS, chatText, chatTitle, factLinkLabel, mentions, nameTelegramBot } from "./chat-voice.js";
35
35
  import { LEAD_SAY_KIND, enqueueLeadLapses, leadSayEarlier, leadSayText, leadSubjectOf } from "./lead-voice.js";
36
36
  import { applyTeamInbound, deliverTeamChats, teamCommand } from "./telegram-team.js";
37
- import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowDecisionAt, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
37
+ import { applyFlowChoiceTap, applyFlowReply, applyFlowTap, FLOW_DECIDE_KEY, flowButtons, flowChoiceButtons, flowChoiceHead, flowDecisionAt, flowDecisionText, flowSendKeyboardRow, flowSentContent } from "./telegram-flow.js";
38
38
  import { flowChoiceAt, flowSendTail, FLOW_CHOOSE_KEY, FLOW_SEND_KEY } from "./flow-send.js";
39
39
  import { cleanFlowMessage, fitFlowMessage } from "./flow-items.js";
40
40
  import { telegramReply } from "./reply-shape.js";
@@ -634,7 +634,7 @@ function decideOfferFor(store, binding, target, projects, now, root) {
634
634
  if (target === null || target === undefined)
635
635
  return null;
636
636
  const principal = verifyApproverStanding(store, binding.approver, binding.approverGeneration, telegramConversationRepos(store, binding.approver, projects));
637
- return principal.ok ? decideOffer(store, target, principal.who, now, root) : null;
637
+ return principal.ok ? decideOffer(store, target, principal.who, now, root, "telegram") : null;
638
638
  }
639
639
  /** 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. */
640
640
  async function deliverTeam(store, botId, transport, clock, report, readProjects, canDeliver, phoneOrigin) {
@@ -1225,13 +1225,23 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1225
1225
  } })();
1226
1226
  if (sentRow !== null)
1227
1227
  button = sentRow.length === 0 ? null : sentRow;
1228
+ // A flow decision or choice for this person leads with what to do and where each button takes the card
1229
+ // (telegram-flow.ts); the flow and zone close it. The saved notice keeps its own words for every other place.
1230
+ const pull = sent === null ? [] : sent.links.flatMap(one => "url" in one ? [`${one.label}: ${one.url}`] : []).slice(0, 1);
1231
+ const choiceHead = choosing === null || sent === null ? null : flowChoiceHead(choosing, sent);
1232
+ const choiceTail = choosing === null ? [] : [...pull, `${choosing.flow.name}${sent?.from === undefined ? "" : ` · after ${sent.from}`}`];
1233
+ if (offer === null && flowKeys !== null && waiting !== null)
1234
+ parts = split(`${notificationIdentity(notification)}${chatText(flowDecisionText(waiting, notification.body), task)}`);
1235
+ else if (offer === null && choiceHead !== null && sent !== null)
1236
+ parts = split(`${notificationIdentity(notification)}${chatText([...choiceHead, "", sent.summary, "", ...choiceTail.flatMap((line, index) => index === 0 ? [line] : ["", line])].join("\n"), task)}`);
1228
1237
  // After research (flow-items.ts): each item numbered under the summary, its words cleaned as the rest of the message's
1229
1238
  // are, within Telegram's limit with every link kept — one message, unless head, links and tail alone overflow it.
1230
1239
  let entities = [];
1231
1240
  if (sent?.items !== undefined && sent.items.length > 0 && offer === null) {
1232
1241
  const subject = chatText(leadSubjectOf(store, notification, binding.approver), task);
1233
- const head = `${notificationIdentity(notification, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}`;
1234
- const message = cleanFlowMessage({ head, summary: sent.summary, items: sent.items, tail: flowSendTail(sent) }, words => chatText(words, task));
1242
+ const head = choiceHead !== null ? `${notificationIdentity(notification)}${choiceHead.join("\n")}`
1243
+ : `${notificationIdentity(notification, title !== undefined && mentions(subject, title) ? undefined : title)}${subject}`;
1244
+ const message = cleanFlowMessage({ head, summary: sent.summary, items: sent.items, tail: choiceHead !== null ? choiceTail : flowSendTail(sent) }, words => chatText(words, task));
1235
1245
  const voiced = fitFlowMessage(message, TELEGRAM_TEXT_MAX, shaped => telegramReply(shaped).text.length).map(telegramReply);
1236
1246
  parts = voiced.map(one => one.text);
1237
1247
  entities = voiced.map(one => one.entities);
@@ -1268,24 +1278,11 @@ async function deliverOne(store, botId, binding, sender, notification, clock, ph
1268
1278
  return { ok: true, receipt: receiptFor(botId, binding.chatId, last) };
1269
1279
  }
1270
1280
  // A decision. Every safety-bearing word goes out before anything tappable
1271
- // exists: recap, question, and every option's consequence, split across as
1272
- // many plain messages as they need — a button whose warning was truncated
1273
- // away is a trap, so the keyboard rides the LAST part only, and only if
1274
- // every earlier part arrived.
1275
- const lines = [
1276
- `${notificationIdentity(notification)}Decision needed`,
1277
- "",
1278
- decision.recap,
1279
- "",
1280
- `Q: ${decision.question}`,
1281
- "",
1282
- ...decision.options.flatMap(option => [
1283
- `[${option.id}] ${option.label}${option.id === decision.recommendation ? " (recommended)" : ""}${option.reversible ? "" : " — IRREVERSIBLE"}`,
1284
- ` ${option.consequence}`,
1285
- ]),
1286
- ...(decision.deadline === null ? [] : ["", `deadline: ${decision.deadline}`]),
1287
- ];
1288
- const parts = split(lines.join("\n"));
1281
+ // exists: the question and every option's consequence first, then the
1282
+ // recap, split across as many plain messages as they need — a button whose
1283
+ // warning was truncated away is a trap, so the keyboard rides the LAST part
1284
+ // only, and only if every earlier part arrived.
1285
+ const parts = split([`${notificationIdentity(notification)}${decisionAsk(decision)}`, "", `Background: ${decision.recap}`].join("\n"));
1289
1286
  for (const part of parts.slice(0, -1)) {
1290
1287
  const sent = await sender(part);
1291
1288
  if (!sent.ok)
@@ -2382,7 +2379,7 @@ function applyCallback(context, update, effects) {
2382
2379
  store.createTelegramAction({ token: confirm, binding: binding.id, decision: decision.id, optionId: option.id, phase: "confirm", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS, ...(digest === undefined ? {} : { noteDigest: digest }) }, clock());
2383
2380
  store.createTelegramAction({ token: cancel, binding: binding.id, decision: decision.id, optionId: option.id, phase: "cancel", chatId: binding.chatId, messageId: placedOn, ttlMs: CONFIRM_TTL_MS }, clock());
2384
2381
  ack("irreversible — confirm it");
2385
- editText(`⚠ ${option.label} is IRREVERSIBLE.\n${option.consequence}\n${draft === null ? "" : `\nWith your note:\n${draft.note.split("\n").map(line => `| ${line}`).join("\n")}\n`}\nConfirm?`, [
2382
+ editText(armedDecisionText(option, draft?.note ?? null), [
2386
2383
  [telegramButton(`⚠ Yes, ${option.label}`, confirm)],
2387
2384
  [telegramButton("Cancel", cancel)],
2388
2385
  ]);
@@ -2430,7 +2427,9 @@ function applyCallback(context, update, effects) {
2430
2427
  store.createTelegramAction({ token: choose, binding: binding.id, decision: decision.id, optionId: option.id, phase: "choose", chatId: binding.chatId, messageId: String(message.message_id) }, clock());
2431
2428
  }
2432
2429
  ack("cancelled");
2433
- editText(`Q: ${decision.question}`, fresh.map(({ option, token: choose }) => [
2430
+ // The card again, within one message; a decision too long for one keeps the consequences its earlier parts show.
2431
+ const ask = decisionAsk(decision);
2432
+ editText(ask.length <= PART_CAP ? ask : `Decide: ${decision.question}`, fresh.map(({ option, token: choose }) => [
2434
2433
  telegramButton(`${option.label}${option.id === decision.recommendation ? " ✓" : ""}${option.reversible ? "" : " ⚠"}`, choose),
2435
2434
  ]));
2436
2435
  }
@@ -2460,10 +2459,27 @@ function answerNow(context, decision, choice, binding, ack, editText) {
2460
2459
  }
2461
2460
  ack(`could not answer: ${answered.reason}`);
2462
2461
  }
2462
+ /** A decision's card, action first: the question, then each option with what it does, then its deadline. */
2463
+ function decisionAsk(decision) {
2464
+ return [
2465
+ `Decide: ${decision.question}`,
2466
+ ...decision.options.map(option => `• ${option.label}${option.id === decision.recommendation ? " (recommended)" : ""}${option.reversible ? "" : " ⚠ can't be undone"}: ${option.consequence}`),
2467
+ ...(decision.deadline === null ? [] : [`Decide by ${decision.deadline.slice(0, 16).replace("T", " ")} UTC`]),
2468
+ ].join("\n");
2469
+ }
2470
+ /** The armed confirm of an option that can't be undone: what it is and what it does, then the note that travels. */
2471
+ function armedDecisionText(option, note) {
2472
+ return [
2473
+ `${option.label}? ⚠ This can't be undone.`,
2474
+ option.consequence,
2475
+ ...(note === null ? [] : ["", "With your note:", ...note.split("\n").map(line => `| ${line}`)]),
2476
+ ].join("\n");
2477
+ }
2463
2478
  function answeredText(store, decision) {
2479
+ const chosen = decision.options.find(one => one.id === decision.choice)?.label ?? decision.choice ?? "?";
2464
2480
  return [
2465
- `✓ ${taskOf(store, decision)} — answered: ${decision.choice ?? "?"}`,
2466
- `by ${decision.answeredBy ?? "?"} via ${decision.answeredVia ?? "?"}`,
2481
+ `✓ Answered: ${chosen}`,
2482
+ `${taskOf(store, decision)} · by ${decision.answeredBy ?? "?"} via ${decision.answeredVia ?? "?"}`,
2467
2483
  // Line-prefixed, never inline: a multiline note must not be able to
2468
2484
  // draw fake status lines (Codex free-text review, finding 7).
2469
2485
  ...(decision.note === null ? [] : ["with note:", ...decision.note.split("\n").map(line => `| ${line}`)]),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.37",
3
+ "version": "0.9.39",
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"
@@ -64,18 +64,18 @@
64
64
  },
65
65
  "devDependencies": {
66
66
  "@tailwindcss/cli": "4.3.3",
67
- "@types/mailparser": "3.4.6",
68
- "@types/node": "26.2.0",
67
+ "@types/mailparser": "3.9.0",
68
+ "@types/node": "26.6.4",
69
69
  "@types/react": "19.3.0",
70
70
  "@types/react-dom": "19.3.0",
71
71
  "esbuild": "0.28.2",
72
- "happy-dom": "20.12.0",
72
+ "happy-dom": "20.14.5",
73
73
  "paper": "0.12.18",
74
74
  "simple-icons": "16.34.0",
75
75
  "tailwindcss": "4.3.3",
76
- "tsx": "4.23.12",
76
+ "tsx": "4.23.15",
77
77
  "typescript": "7.0.2",
78
- "vitest": "4.1.10"
78
+ "vitest": "4.1.11"
79
79
  },
80
80
  "files": [
81
81
  "dist",
@@ -93,6 +93,11 @@
93
93
  "scripts/fixtures/restart-service.mjs",
94
94
  "THIRD_PARTY_NOTICES.md"
95
95
  ],
96
+ "overrides": {
97
+ "@tailwindcss/cli@4.3.3": {
98
+ "@parcel/watcher": "2.6.0"
99
+ }
100
+ },
96
101
  "dependencies": {
97
102
  "@radix-ui/react-collapsible": "1.1.20",
98
103
  "@radix-ui/react-dialog": "1.1.23",
@@ -105,15 +110,15 @@
105
110
  "@radix-ui/react-switch": "1.3.7",
106
111
  "@radix-ui/react-tabs": "1.1.21",
107
112
  "@radix-ui/react-tooltip": "1.2.16",
108
- "@slack/socket-mode": "3.0.1",
109
- "@xyflow/react": "12.11.6",
113
+ "@slack/socket-mode": "3.1.0",
114
+ "@xyflow/react": "12.12.0",
110
115
  "class-variance-authority": "0.7.1",
111
116
  "clsx": "2.1.1",
112
117
  "discord.js": "14.27.0",
113
- "imapflow": "2.0.6",
114
- "lucide-react": "1.47.0",
115
- "mailparser": "3.9.28",
116
- "nodemailer": "10.0.10",
118
+ "imapflow": "2.2.5",
119
+ "lucide-react": "1.52.0",
120
+ "mailparser": "3.9.36",
121
+ "nodemailer": "10.0.15",
117
122
  "react": "19.3.0",
118
123
  "react-dom": "19.3.0",
119
124
  "sonner": "2.0.8",