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.
@@ -0,0 +1,175 @@
1
+ import { phoneText } from "./telegram-status.js";
2
+ /** The bot's display name in every chat app. */
3
+ export const BOT_NAME = "Toolroll";
4
+ /** Updates for one person landing within this window become one message, edited in place as it grows. */
5
+ export const BATCH_MS = 2 * 60_000;
6
+ export const SHORT_TITLE_MAX = 60;
7
+ /** A token that could be an id: letters and digits joined by `-` or `_`, or with a digit in it (`release-099b`,
8
+ * `t42`). Words such as `node-20` and `utf-8` look the same, so a token goes only when it names a real task. */
9
+ const ID_SHAPED = /\(?(?<![\p{L}\p{N}._/-])[\p{L}\p{N}]+(?:[-_.][\p{L}\p{N}]+)*(?![\p{L}\p{N}_/-]|\.[\p{L}\p{N}])\)?/gu;
10
+ /** An id that could be mistaken for nothing else (`release-099b`, `fix_tax`, `t42`). A one-word id such as `tidy`
11
+ * is also a word, and prose keeps its words. */
12
+ const idLike = (id) => /[-_\d]/.test(id);
13
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
14
+ /** A short human summary of a task's title, at most 60 characters: no "— revision", and no task ids. Only the
15
+ * task's own id and tokens `isTaskId` names as real tasks are dropped; `node-20` and `utf-8` stay. */
16
+ export function shortTitle(title, taskId, isTaskId) {
17
+ let text = phoneText(title ?? "", 400);
18
+ // "— revision", "- revision 2", "(revision)": a revision is the same work to a person.
19
+ text = text.replace(/(?:(?:\s*[—–]+|\s+-+)\s*revision\b(?:\s*#?\d+)?|\s*[([]\s*revision(?:\s*#?\d+)?\s*[)\]])/giu, "");
20
+ text = text.replace(ID_SHAPED, token => {
21
+ const bare = token.replace(/^\(|\)$/g, "");
22
+ return idLike(bare) && (bare === taskId || isTaskId?.(bare) === true) ? " " : token;
23
+ });
24
+ text = text.replace(/\(\s*\)/g, " ").replace(/\s+/g, " ").replace(/^[\s·:,;—–-]+|[\s·:,;—–-]+$/gu, "").trim();
25
+ if (text === "") {
26
+ // A task filed with only its id as a title: the words, never the slug.
27
+ const words = (taskId ?? "").replace(/[-_]+/g, " ").trim();
28
+ return words === "" ? "A task" : shortTitle(words[0].toUpperCase() + words.slice(1));
29
+ }
30
+ if (text.length <= SHORT_TITLE_MAX)
31
+ return text;
32
+ const cut = text.slice(0, SHORT_TITLE_MAX - 1);
33
+ const space = cut.lastIndexOf(" ");
34
+ return `${(space > 20 ? cut.slice(0, space) : cut).replace(/[\s·:,;—–-]+$/u, "")}…`;
35
+ }
36
+ /** A task's title as every chat says it: short, with its own id and any other real task's id dropped. */
37
+ export function chatTitle(store, taskId) {
38
+ return shortTitle(store.getTask(taskId)?.title, taskId, token => token !== taskId && store.getTask(token) !== null);
39
+ }
40
+ /** A title inside a sentence or list: "faster tests", but "API keys" and "iOS" keep their capitals. */
41
+ export function inSentence(summary) {
42
+ return /^\p{Lu}\p{Ll}/u.test(summary) ? summary[0].toLowerCase() + summary.slice(1) : summary;
43
+ }
44
+ /** The two buttons under a result ready for a person: the real next step, then a look first. */
45
+ export const READY_ACTIONS = { merge: "Merge", complete: "Mark complete", look: "Look first" };
46
+ /** Whether this line asks for the result's next step, so it carries [Merge] or [Mark complete] and [Look first]. */
47
+ export function asksToFinish(fact) {
48
+ return fact.update == null && fact.headline === "Ready for review" && !fact.report;
49
+ }
50
+ /** One update, said plainly: what happened, then what happens next. The lead says "I" for what it did. */
51
+ export function finishedLine(fact) {
52
+ if (fact.update != null)
53
+ return fact.update.words;
54
+ const name = fact.summary, lower = inSentence(fact.summary);
55
+ const ready = fact.lead === true ? `I finished ${lower}` : `${name} is ready`;
56
+ switch (fact.headline) {
57
+ case "Ready for review":
58
+ if (fact.report)
59
+ return fact.lead === true ? `I wrote up ${lower}. The report is ready to read.` : `${name}: the report is ready to read.`;
60
+ if (fact.checks === "passed")
61
+ return `${ready}. Your tests passed. ${fact.pullRequest === true ? "Merge it?" : "Mark it complete?"}`;
62
+ if (fact.checks === "off")
63
+ return `${ready}. Checks are off for this project, so look it over first.`;
64
+ return `${ready}, but no tests ran. Look it over first.`;
65
+ case "Failed":
66
+ if (fact.checks === "failed")
67
+ return fact.lead === true
68
+ ? `I built ${lower}, but its tests failed. It waits for you: retry or ask for changes.`
69
+ : `${name} is built, but its tests failed. It waits for you: retry or ask for changes.`;
70
+ return fact.lead === true
71
+ ? `I couldn't finish ${lower}. The work so far is kept; retry when you're ready.`
72
+ : `${name} stopped before it finished. The work so far is kept; retry when you're ready.`;
73
+ case "Complete":
74
+ if (fact.lead === true && fact.completedBy)
75
+ return `I marked ${lower} complete.`;
76
+ return fact.completedBy ? `${name} is done. ${fact.completedBy} marked it complete.` : `${name} is done.`;
77
+ case "Needs you":
78
+ return fact.lead === true ? `I need your decision on ${lower} before I can go on.` : `${name} needs your decision before it can continue.`;
79
+ default:
80
+ return `${name}: ${fact.headline.toLowerCase()}.`;
81
+ }
82
+ }
83
+ /** One task in a list of updates: "faster tests is ready", "cleanup failed". */
84
+ function phraseOf(fact) {
85
+ const name = inSentence(fact.summary);
86
+ if (fact.update != null)
87
+ return `${name} ${fact.update.phrase}`;
88
+ switch (fact.headline) {
89
+ case "Ready for review": return `${name} is ready`;
90
+ case "Failed": return `${name} failed`;
91
+ case "Complete": return `${name} is done`;
92
+ case "Needs you": return `${name} needs you`;
93
+ default: return `${name}: ${fact.headline.toLowerCase()}`;
94
+ }
95
+ }
96
+ /** Several updates in one message: "4 tasks finished: faster tests, cleanup, …" (or "3 updates: …" when not all of
97
+ * them are finished work), then what broke and what happens next. */
98
+ export function batchLine(facts) {
99
+ if (facts.length === 1)
100
+ return finishedLine(facts[0]);
101
+ const finishedOnly = facts.every(one => one.update == null);
102
+ const names = finishedOnly ? facts.map(one => inSentence(one.summary)) : facts.map(phraseOf);
103
+ const shown = names.slice(0, 3).join(", ");
104
+ const first = `${facts.length} ${finishedOnly ? "tasks finished" : "updates"}: ${shown}${names.length > 3 ? ", …" : "."}`;
105
+ // What happens next: anything that broke or needs a decision waits for the person.
106
+ const failed = facts.filter(one => one.headline === "Failed");
107
+ const needs = facts.filter(one => one.headline === "Needs you").length;
108
+ const broke = failed.length !== 1 ? `${failed.length} failed`
109
+ : failed[0].checks === "failed" ? `tests failed on ${inSentence(failed[0].summary)}` : `${inSentence(failed[0].summary)} stopped before it finished`;
110
+ const waits = [
111
+ ...(failed.length === 0 ? [] : [broke]),
112
+ ...(needs === 0 ? [] : [needs === facts.length ? "each needs your decision" : `${needs} ${needs === 1 ? "needs" : "need"} your decision`]),
113
+ ];
114
+ if (waits.length === 0)
115
+ return first;
116
+ const said = waits.join(", and ");
117
+ return `${first}\n${said[0].toUpperCase()}${said.slice(1)}; ${failed.length + needs === 1 ? "it waits" : "they wait"} for you.`;
118
+ }
119
+ /** The old product name a bot may still wear from before the rename. */
120
+ const OLD_NAME = /standing\s*-?\s*orders/i;
121
+ /**
122
+ * Name the Telegram bot Toolroll. On pairing it always becomes Toolroll; on upgrade only a bot still called
123
+ * StandingOrders is renamed, so a name a person chose later is kept. Best effort: a refused or failed call
124
+ * leaves the old name and never blocks pairing or delivery. Returns whether a rename was sent and accepted.
125
+ */
126
+ export async function nameTelegramBot(transport, when) {
127
+ try {
128
+ const current = await transport("getMyName", {});
129
+ const name = current.ok ? current.result?.name : undefined;
130
+ const shown = typeof name === "string" ? name : null;
131
+ if (shown === BOT_NAME)
132
+ return false;
133
+ if (when === "upgrade" && (shown === null || !OLD_NAME.test(shown)))
134
+ return false;
135
+ const set = await transport("setMyName", { name: BOT_NAME });
136
+ return set.ok;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ /** The last word on any pushed chat text: a task's own id (bare or in brackets), a "— revision" suffix and a
143
+ * "Replaced by <id>" never reach a chat, whatever words the fact was recorded with. A bare id reads as the
144
+ * task's short title when one is given, else "this task". */
145
+ export function chatText(text, tasks = []) {
146
+ let out = text.replace(/(?:(?:[ \t]*[—–]+|[ \t]+-+)[ \t]*revision\b(?:[ \t]*#?\d+)?|[ \t]*[([][ \t]*revision(?:[ \t]*#?\d+)?[ \t]*[)\]])/giu, "");
147
+ out = out.replace(/\bReplaced by (?!a newer task)[^\s.,;:!?)]+/gu, "Replaced by a newer task");
148
+ for (const task of tasks) {
149
+ const id = typeof task === "string" ? task : task?.id;
150
+ if (!id || !idLike(id))
151
+ continue;
152
+ const title = typeof task === "object" && task !== null ? task.title : undefined;
153
+ out = out.replace(new RegExp(`[ \\t]*\\(${escape(id)}\\)|(?<![\\p{L}\\p{N}_/=-])${escape(id)}(?![\\p{L}\\p{N}_-])`, "gu"), match => match.trimStart().startsWith("(") ? "" : title ?? "this task");
154
+ }
155
+ return out;
156
+ }
157
+ /** The one button a plain fact may carry: its label names where its machine-minted console path goes; nothing in
158
+ * it comes from the fact's text. */
159
+ export function factLinkLabel(path) {
160
+ if (/^\/review\?result=[^&]+&run=\d+&tab=checks$/.test(path))
161
+ return "Inspect result";
162
+ if (/^\/t\/[^?#]+#merge$/.test(path))
163
+ return "Merge";
164
+ if (/^\/chat\?task=[^&]+&result=/.test(path))
165
+ return "Open result";
166
+ if (/^\/(?:chat\?task=|t\/)/.test(path))
167
+ return "Open task";
168
+ if (/^\/d\//.test(path))
169
+ return "Open decision";
170
+ return "Open console";
171
+ }
172
+ /** Whether the words already name this title as a whole phrase (so it need not be said twice). */
173
+ export function mentions(text, title) {
174
+ return new RegExp(`(?<![\\p{L}\\p{N}_-])${escape(title)}(?![\\p{L}\\p{N}_-])`, "u").test(text);
175
+ }
@@ -273,6 +273,7 @@ export async function deliverDiscordPart(options) {
273
273
  }))
274
274
  : []),
275
275
  ...link(options.origin(), content.link),
276
+ ...(content.also ?? []).flatMap(one => link(options.origin(), one)),
276
277
  ];
277
278
  let target = content.edit ?? row.message;
278
279
  if (target && content.image && row.uploaded) {
package/dist/operate.d.ts CHANGED
@@ -50,6 +50,8 @@ export type OperateOptions = {
50
50
  gitRunner?: CommandRunner;
51
51
  /** Injected by tests: the Telegram Bot API. Production dials the real one. */
52
52
  telegramTransport?: TelegramTransport;
53
+ /** Injected by tests: the environment checked for a fenced network before Telegram is reached (default: this process's, with the real transport). */
54
+ telegramEnv?: Record<string, string | undefined>;
53
55
  publishExec?: PublishExec;
54
56
  /** Injected by tests: the stop fence a watch would set. */
55
57
  shouldStop?: () => boolean;
package/dist/operate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { UNSENT_REPLY_MS } from "./telegram-settings.js";
1
2
  import { maybeTriggerRepair } from "./dispose.js";
2
3
  import { CHECK_LEVEL_HINTS, CHECK_LEVEL_WORDS, isCheckLevel, liveQuickCommand, projectCheckLevel, quickVerifyKey, setProjectCheckLevel, setTaskCheckLevel, suggestQuickCommand } from "./check-levels.js";
3
4
  import { fileAddTestsTask, followUpChecksOf, requestFollowUpChecks, runFollowUpCheck, runWaitingChecks } from "./result-follow-ups.js";
@@ -138,7 +139,7 @@ import { diagnoseTaskDispatch, diagnosisIsDispatchable, scopeApprovedForDispatch
138
139
  import { createDecisionServer } from "./serve.js";
139
140
  import { readTailnetNames } from "./tailnet.js";
140
141
  import { daemonLaunchCommand, daemonStatus, installReplacingLegacy, installedLegacyDaemon, planDaemon, uninstallDaemon, awaitFreshHeartbeat, } from "./daemon.js";
141
- import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
142
+ import { bridgePass, clearBotToken, createTransport, followBridge, hashPairingCode, loadBotToken, mintPairingCode, networkFence, networkFenceLine, redactToken, saveBotToken, PAIRING_TTL_MS, TOKEN_ENV, telegramHookSecret, telegramPushUrl, } from "./telegram.js";
142
143
  import { scanRepo } from "./capscan.js";
143
144
  import { computeGaps, describeCapability } from "./gaps.js";
144
145
  import { ask, askHidden, confirm, interactive, underAgent } from "./prompt.js";
@@ -198,7 +199,7 @@ import { updateAdmissionPaused, UPDATE_PAUSED } from "./desktop-update-gate.js";
198
199
  import { beads } from "./beads.js";
199
200
  import { githubIssues } from "./issues.js";
200
201
  import { installationStatus, renderInstallationStatus, renderTaskWait, repairStaleStatuses, taskWaitSnapshot } from "./lead-status.js";
201
- import { latestRelease, newerRelease, notifySecurityRelease, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
202
+ import { latestRelease, newerRelease, notifySecurityRelease, notifyVersionLive, recordRunnerVersion, startUpdateChecks, updateLine } from "./releases.js";
202
203
  import { installMethod } from "./install-method.js";
203
204
  import { PACKAGE_VERSION } from "./version.js";
204
205
  /**
@@ -820,6 +821,7 @@ export async function runOperate(command, argv, write, options = {}) {
820
821
  ...(options.agentRunner === undefined ? {} : { agentRunner: options.agentRunner }),
821
822
  ...(options.gitRunner === undefined ? {} : { gitRunner: options.gitRunner }),
822
823
  ...(options.telegramTransport === undefined ? {} : { telegramTransport: options.telegramTransport }),
824
+ ...(options.telegramEnv === undefined ? {} : { telegramEnv: options.telegramEnv }),
823
825
  ...(options.publishExec === undefined ? {} : { publishExec: options.publishExec }),
824
826
  ...(options.dispatchAdapter === undefined ? {} : { dispatchAdapter: options.dispatchAdapter }),
825
827
  ...(options.flowTriggerIo === undefined ? {} : { flowTriggerIo: options.flowTriggerIo }),
@@ -7288,6 +7290,50 @@ async function daemonCommand(positional, flags, context) {
7288
7290
  const WATCH_LEASE_MS = 90_000;
7289
7291
  const WATCH_HEARTBEAT_MS = 30_000;
7290
7292
  /** The start repair runs once per process, not once per project loop. */
7293
+ /**
7294
+ * The Telegram follower: one actor on the wire that sends notifications and
7295
+ * replies and reads what arrives, all over the same transport. Null when no
7296
+ * bot token is saved, or when this process's network is fenced (it says so
7297
+ * once instead of failing every send).
7298
+ */
7299
+ function startTelegramFollower(args) {
7300
+ const { context, progress, label } = args;
7301
+ const source = loadBotToken(process.env, context.telegramTokenFile);
7302
+ if (source === null)
7303
+ return null;
7304
+ const fence = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
7305
+ if (fence !== null) {
7306
+ progress(networkFenceLine(fence));
7307
+ return null;
7308
+ }
7309
+ const transport = context.telegramTransport ?? createTransport(source.token);
7310
+ // v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
7311
+ const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
7312
+ return followBridge(context.store, {
7313
+ push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
7314
+ readProjects: telegramReadProjects(context),
7315
+ canDeliver: telegramCanDeliver(context, source.token),
7316
+ conversation: telegramConversation(context, args.publicUrl === undefined ? {} : { serverOrigin: args.publicUrl }),
7317
+ botId: source.botId,
7318
+ transport,
7319
+ signal: args.signal,
7320
+ ...(args.owner === undefined ? {} : { owner: args.owner }),
7321
+ onCycle: cycle => {
7322
+ // progress(), not write(): under --json this line went to stdout
7323
+ // BESIDE the final envelope — the one stdout contamination in the
7324
+ // watch (round-4 finding 12).
7325
+ progress(`${label} sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
7326
+ ((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
7327
+ ((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
7328
+ ((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
7329
+ ((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
7330
+ (cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
7331
+ },
7332
+ }).catch(error => {
7333
+ progress(`${args.died.prefix}the telegram follower died — ${describe(error)}; ${args.died.then}`);
7334
+ return null;
7335
+ });
7336
+ }
7291
7337
  let staleStatusesRepaired = false;
7292
7338
  async function runWatchLoop(args) {
7293
7339
  const { context, flags, runner, token, repo, progress } = args;
@@ -7324,6 +7370,11 @@ async function runWatchLoop(args) {
7324
7370
  // attributes to this episode by runner and window, and `brief
7325
7371
  // --latest-watch` bounds itself to exactly it.
7326
7372
  store.startWatchEpisode({ repo, runner, incarnation }, new Date());
7373
+ // A deploy's one chat line, "Toolroll <version> is live.", when this worker last ran an older version.
7374
+ try {
7375
+ notifyVersionLive(store, dirname(context.databaseFile), PACKAGE_VERSION, new Date());
7376
+ }
7377
+ catch { /* A display fact; never blocks the worker. */ }
7327
7378
  recordRunnerVersion(dirname(context.databaseFile), runner, PACKAGE_VERSION, new Date());
7328
7379
  args.onReady?.();
7329
7380
  // Once per worker start, before any pass: statuses an older build left
@@ -7361,38 +7412,13 @@ async function runWatchLoop(args) {
7361
7412
  // moment they arrive, and answering bumps the wake sequence, so the very
7362
7413
  // loop below wakes and resumes the freed task — phone to build, seconds.
7363
7414
  // The poll lease keeps this the only live poller; a cron `bridge
7364
- // telegram` overlapping it loses the lease race and reports busy.
7365
- let follower = null;
7366
- const followSource = loadBotToken(process.env, context.telegramTokenFile);
7367
- if (followSource !== null) {
7368
- const transport = context.telegramTransport ?? createTransport(followSource.token);
7369
- const publicUrl = text(flags, "public-url");
7370
- // v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
7371
- const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
7372
- follower = followBridge(store, {
7373
- push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
7374
- readProjects: telegramReadProjects(context),
7375
- canDeliver: telegramCanDeliver(context, followSource.token),
7376
- conversation: telegramConversation(context, publicUrl === undefined ? {} : { serverOrigin: publicUrl }),
7377
- botId: followSource.botId,
7378
- transport,
7379
- signal: followController.signal,
7380
- onCycle: cycle => {
7381
- // progress(), not write(): under --json this line went to stdout
7382
- // BESIDE the final envelope — the one stdout contamination in the
7383
- // watch (round-4 finding 12).
7384
- progress(`watch: bridge sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
7385
- ((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
7386
- ((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
7387
- ((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
7388
- ((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
7389
- (cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
7390
- },
7391
- }).catch(error => {
7392
- progress(`watch: the telegram follower died — ${describe(error)}; taps wait for the next watch`);
7393
- return null;
7394
- });
7395
- }
7415
+ // telegram` overlapping it loses the lease race and reports busy. Under
7416
+ // `up` the service runs the one follower itself and project loops leave
7417
+ // Telegram to it.
7418
+ const serviceBridge = args.serviceBridge === true;
7419
+ const follower = serviceBridge
7420
+ ? null
7421
+ : startTelegramFollower({ context, publicUrl: text(flags, "public-url"), signal: followController.signal, progress, label: "watch: bridge", died: { prefix: "watch: ", then: "taps wait for the next watch" } });
7396
7422
  const slackFollower = followSlack({ store, dir: dirname(context.databaseFile), signal: followController.signal,
7397
7423
  readProjects: telegramReadProjects(context), evidenceRoot: context.evidenceRoot,
7398
7424
  ...(context.mateSeams?.subscriptionRunner ? { subscriptionRunner: context.mateSeams.subscriptionRunner } : {}),
@@ -7685,7 +7711,7 @@ async function runWatchLoop(args) {
7685
7711
  // pass is the fallback shape for a watch started before a token existed.
7686
7712
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
7687
7713
  lastBridge = now;
7688
- const source = loadBotToken(process.env, context.telegramTokenFile);
7714
+ const source = serviceBridge ? null : loadBotToken(process.env, context.telegramTokenFile);
7689
7715
  const dir = dirname(context.databaseFile);
7690
7716
  // Exactly ONE service carries the pages — the chosen primary, or
7691
7717
  // the sensible implicit one. Telegram keeps draining taps and
@@ -8533,6 +8559,7 @@ async function upCommand(flags, context) {
8533
8559
  onFollowController: controller => followControllers.push(controller),
8534
8560
  containmentLines,
8535
8561
  passes: projectPasses,
8562
+ serviceBridge: true,
8536
8563
  onReady: () => {
8537
8564
  markReady();
8538
8565
  if (bind)
@@ -8552,6 +8579,33 @@ async function upCommand(flags, context) {
8552
8579
  await ready;
8553
8580
  };
8554
8581
  await Promise.all(repos.map(repo => launchRepo(repo, false)));
8582
+ // Telegram: one follower for the whole service, never one per project
8583
+ // loop. Replies and notifications go out from it, over the same transport
8584
+ // in the same cycle (Oct 2: a reply sent by a project loop's own follower
8585
+ // failed "fetch failed" while notifications went out). A token saved
8586
+ // while the service runs connects within seconds; a follower that dies
8587
+ // is started again.
8588
+ const telegramController = new AbortController();
8589
+ followControllers.push(telegramController);
8590
+ const telegramSupervisor = (async () => {
8591
+ const pause = (ms) => new Promise(resolvePause => {
8592
+ const timer = setTimeout(done, ms);
8593
+ function done() { clearTimeout(timer); telegramController.signal.removeEventListener("abort", done); resolvePause(); }
8594
+ telegramController.signal.addEventListener("abort", done, { once: true });
8595
+ });
8596
+ while (!stopping && !telegramController.signal.aborted) {
8597
+ const follower = startTelegramFollower({
8598
+ context, publicUrl: text(flags, "public-url"), signal: telegramController.signal, progress, label: "telegram:",
8599
+ died: { prefix: "", then: "starting it again" }, owner: `service-${randomUUID()}`,
8600
+ });
8601
+ if (follower !== null)
8602
+ await follower;
8603
+ // Without a token (or on a fenced network) look again shortly; the fence line is said once.
8604
+ if (follower === null && loadBotToken(process.env, context.telegramTokenFile) !== null)
8605
+ break;
8606
+ await pause(follower === null ? 2_000 : 5_000);
8607
+ }
8608
+ })();
8555
8609
  const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
8556
8610
  const rejected = new Set();
8557
8611
  const registrySupervisor = (async () => {
@@ -8613,6 +8667,10 @@ async function upCommand(flags, context) {
8613
8667
  });
8614
8668
  };
8615
8669
  observeReadiness();
8670
+ try {
8671
+ notifyVersionLive(context.store, dirname(context.databaseFile), PACKAGE_VERSION, clock());
8672
+ }
8673
+ catch { /* A display fact; never blocks the worker. */ }
8616
8674
  recordRunnerVersion(dirname(context.databaseFile), runnerName, PACKAGE_VERSION, clock());
8617
8675
  const runnerHeartbeat = setInterval(() => {
8618
8676
  const beat = heartbeatRunner(store, runnerName, runnerToken, clock());
@@ -8682,6 +8740,7 @@ async function upCommand(flags, context) {
8682
8740
  await stopped;
8683
8741
  await registrySupervisor;
8684
8742
  await Promise.all([...loopResults.values()]);
8743
+ await telegramSupervisor;
8685
8744
  clearInterval(runnerHeartbeat);
8686
8745
  if (runTimer !== undefined)
8687
8746
  clearTimeout(runTimer);
@@ -8729,6 +8788,7 @@ async function upCommand(flags, context) {
8729
8788
  * bridge telegram token [<bot-token>|--clear] set the credential file
8730
8789
  * bridge telegram status
8731
8790
  * bridge telegram digest [--every 30m|2h|24h | --off] away mode: routine facts batch, decisions still page
8791
+ * bridge telegram retry send unsent assistant replies again now
8732
8792
  *
8733
8793
  * The bot token comes from ${TOKEN_ENV} or the credential file this command
8734
8794
  * writes (0600, beside the database). Cron the pass right after tick; a
@@ -8745,7 +8805,14 @@ async function bridgeCommand(positional, flags, context) {
8745
8805
  return demoFence;
8746
8806
  const [channel, action] = positional;
8747
8807
  if (channel !== "telegram") {
8748
- return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest]`", EXIT.usage);
8808
+ return fail(write, json, "bridge", "usage", "`toolroll bridge telegram [pair|unpair|token|status|digest|retry]`", EXIT.usage);
8809
+ }
8810
+ if (action === "retry") {
8811
+ // Replies already written and fenced on the pairing like every send: this only moves their next attempt to now.
8812
+ const retried = store.retryTelegramReplies(null, clock());
8813
+ return succeed(write, json, "bridge retry", { retried }, () => [
8814
+ retried === 0 ? "No unsent Telegram replies." : `Sending ${retried} Telegram ${retried === 1 ? "reply" : "replies"} again on the next bridge pass.`,
8815
+ ]);
8749
8816
  }
8750
8817
  if (action === "token") {
8751
8818
  const value = positional[2];
@@ -8862,6 +8929,9 @@ async function bridgeCommand(positional, flags, context) {
8862
8929
  if (source === null) {
8863
8930
  return fail(write, json, "bridge", "no-token", `no bot token — set ${TOKEN_ENV}, run \`toolroll bridge telegram token <t>\`, or use the serve settings card`, EXIT.refused);
8864
8931
  }
8932
+ const fenced = networkFence(context.telegramEnv ?? (context.telegramTransport === undefined ? process.env : {}));
8933
+ if (fenced !== null)
8934
+ return fail(write, json, "bridge", "network-fenced", networkFenceLine(fenced), EXIT.refused);
8865
8935
  const transport = context.telegramTransport ?? createTransport(source.token);
8866
8936
  // --follow: stay on the wire. One long-poll actor holds the poll lease;
8867
8937
  // an answer tapped on a phone lands in seconds instead of at the next
@@ -9579,7 +9649,18 @@ async function statusCommand(positional, flags, context) {
9579
9649
  const projects = projectBuildsStatus(context);
9580
9650
  const projectsLine = projects.length === 0 ? null
9581
9651
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9582
- return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting }, () => [...(waiting === null ? [] : [waiting.words]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9652
+ // A Telegram reply written but still not sent after two minutes, with its Retry.
9653
+ const unsent = context.store.unsentTelegramReplies(null, null, new Date(context.clock().getTime() - UNSENT_REPLY_MS));
9654
+ const unsentLine = unsentRepliesLine(unsent);
9655
+ const unsentReplies = unsent.length === 0 ? {} : { unsentReplies: unsent.map(one => ({ ...one, retry: "toolroll bridge telegram retry" })) };
9656
+ return succeed(context.write, context.json, command, { ...status, projects, ...update, ...integrations, ...updateWaiting, ...unsentReplies }, () => [...(waiting === null ? [] : [waiting.words]), ...(unsentLine === null ? [] : [unsentLine]), ...renderInstallationStatus(status), ...(projectsLine === null ? [] : [projectsLine]), ...(line === null ? [] : [line]), ...(brokenLine === null ? [] : [brokenLine])]);
9657
+ }
9658
+ function unsentRepliesLine(unsent) {
9659
+ if (unsent.length === 0)
9660
+ return null;
9661
+ const oldest = unsent[0];
9662
+ const what = unsent.length === 1 ? "A Telegram reply hasn't" : `${unsent.length} Telegram replies haven't`;
9663
+ return `${what} been sent since ${oldest.since.slice(11, 16)} UTC${oldest.error === null ? "" : ` (${oldest.error})`}. Retry: toolroll bridge telegram retry`;
9583
9664
  }
9584
9665
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9585
9666
  function projectBuildsStatus(context) {
@@ -232,6 +232,7 @@ function deleteRows(store, repo, d, now) {
232
232
  del("telegram_conversation", IN("id"), conversations);
233
233
  del("telegram_outbound_message", `project = ? OR ${IN("notification")} OR ${IN("task_ref")} OR ${IN("source_run")}`, repo, notifications, T, R);
234
234
  del("chat_card_task", IN("task_ref"), T);
235
+ del("chat_batch_item", IN("task_ref"), T);
235
236
  del("push_delivery", IN("notification"), notifications);
236
237
  del("notification_actor", IN("notification"), notifications);
237
238
  del("notification_delivery", IN("notification"), notifications);
@@ -83,3 +83,12 @@ export declare function latestVersionNow(io?: {
83
83
  }): Promise<{
84
84
  version: string;
85
85
  }>;
86
+ /** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
87
+ * have such a line; null otherwise. */
88
+ export declare function releaseSummary(notes: string, version: string): string | null;
89
+ /**
90
+ * A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
91
+ * notes have one). Once per version, addressed to each instance operator, and only when a worker on this
92
+ * computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
93
+ */
94
+ export declare function notifyVersionLive(store: Store, dir: string, version: string, now: Date): number;
package/dist/releases.js CHANGED
@@ -236,3 +236,33 @@ export async function latestVersionNow(io = {}) {
236
236
  throw new Error("npm did not name a version");
237
237
  return { version };
238
238
  }
239
+ /** What a release says about itself in one line ("Release 0.9.9: lighter tests, cleaner status"), when its notes
240
+ * have such a line; null otherwise. */
241
+ export function releaseSummary(notes, version) {
242
+ const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
243
+ const line = new RegExp(`^\\s*(?:#{1,6}\\s*)?(?:Toolroll\\s+)?(?:Release\\s+)?v?${escaped}\\s*[:—–-]\\s*(.+?)\\s*$`, "im").exec(notes);
244
+ const summary = line?.[1]?.replace(/[*_`]/g, "").replace(/\s+/g, " ").replace(/[.\s]+$/, "").trim() ?? "";
245
+ if (summary === "")
246
+ return null;
247
+ return summary.length <= 80 ? summary : `${summary.slice(0, 79).replace(/[\s,;:]+\S*$/, "")}…`;
248
+ }
249
+ /**
250
+ * A deploy says one thing in chat: "Toolroll 0.9.9 is live." (with the release's own one-line summary when its
251
+ * notes have one). Once per version, addressed to each instance operator, and only when a worker on this
252
+ * computer last ran an older version: a fresh install is not news. Call before recording this runner's version.
253
+ */
254
+ export function notifyVersionLive(store, dir, version, now) {
255
+ const before = runnerVersions(dir);
256
+ if (!VERSION.test(version) || !before.some(one => isNewer(version, one.version)) || before.some(one => isNewer(one.version, version)))
257
+ return 0;
258
+ const cached = cachedRelease(dir);
259
+ const summary = cached?.version === version ? releaseSummary(cached.notes, version) : null;
260
+ const line = summary === null ? `Toolroll ${version} is live.` : `Toolroll ${version} is live: ${summary}.`;
261
+ let queued = 0;
262
+ for (const recipient of store.accountFacts().map(one => one.name).filter(name => store.isInstanceOperator(name))) {
263
+ if (store.enqueueNotification({ dedupeKey: `release:live:${version}:${recipient}`, kind: "version-live", subject: line, body: "",
264
+ recipient, source: { installation: true } }, now))
265
+ queued++;
266
+ }
267
+ return queued;
268
+ }
package/dist/serve.js CHANGED
@@ -1674,7 +1674,7 @@ export function createDecisionServer(options) {
1674
1674
  // separately proves cookie, approver standing and password; instance bot
1675
1675
  // settings remain outside this exact allowlist.
1676
1676
  if ((request.method === "GET" && path === "/settings/telegram") ||
1677
- (request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair"].includes(path)))
1677
+ (request.method === "POST" && ["/settings/telegram/pair", "/settings/telegram/unpair", "/settings/telegram/retry"].includes(path)))
1678
1678
  return true;
1679
1679
  // Coding routes require an instance operator, then prove saved ownership and project access.
1680
1680
  if (path === "/code" || path.startsWith("/code/"))
@@ -4066,7 +4066,7 @@ export function createDecisionServer(options) {
4066
4066
  // The settings view holds no secret (a pairing code only appears in
4067
4067
  // the response to Pair, which stays script-free), so it joins the
4068
4068
  // workspace like every other settings page.
4069
- return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf), { chrome: chromeFor(project, "settings") }));
4069
+ return sendScreen(response, 200, screen("Telegram", telegramSettingsHtml(store, botId, who.name, who.session.csrf, { now }), { chrome: chromeFor(project, "settings") }));
4070
4070
  }
4071
4071
  if (url.pathname === "/settings/teams") {
4072
4072
  if (who.via !== "cookie" || who.role !== "approver" || restricted() || !options.configDir)
@@ -7885,6 +7885,13 @@ export function createDecisionServer(options) {
7885
7885
  }
7886
7886
  return redirect(response, "/settings/slack");
7887
7887
  }
7888
+ if (url.pathname === "/settings/telegram/retry") {
7889
+ // The signed-in person's own unsent replies only: already written and fenced on the pairing; this sends them again now.
7890
+ if (who.via !== "cookie" || who.role !== "approver")
7891
+ return refuse(response, who, 403, "An approver can retry their own replies.", "/settings");
7892
+ store.retryTelegramReplies(who.name, now);
7893
+ return redirect(response, "/settings/telegram");
7894
+ }
7888
7895
  if (url.pathname === "/settings/telegram/pair" || url.pathname === "/settings/telegram/unpair") {
7889
7896
  // The person's own pairing, under their password: a code minted for
7890
7897
  // them alone, or their own chats revoked. Teammates' pairings are
@@ -360,6 +360,8 @@ export async function deliverSlackPart(options) {
360
360
  }))
361
361
  : []),
362
362
  ...linkButton(options.origin(), content.link),
363
+ // [Look first] beside [Merge] or [Mark complete]: each button's own action id.
364
+ ...(content.also ?? []).flatMap((one, index) => linkButton(options.origin(), one).map(button => ({ ...button, action_id: `toolroll_link_${index + 2}` }))),
363
365
  ];
364
366
  const target = content.edit ?? row.message;
365
367
  const args = {
package/dist/store.d.ts CHANGED
@@ -990,6 +990,18 @@ export type ChatCard = {
990
990
  digest: string | null;
991
991
  tasks: number[];
992
992
  };
993
+ export type ChatBatch = {
994
+ id: number;
995
+ destination: string;
996
+ openedAt: string;
997
+ message: string | null;
998
+ digest: string | null;
999
+ items: {
1000
+ taskRef: number;
1001
+ run: number | null;
1002
+ notification: number | null;
1003
+ }[];
1004
+ };
993
1005
  /**
994
1006
  * What one recovery walk over a gone runner's work settled (P0.1a): the run
995
1007
  * ids it finished as failed/interrupted and the built-in task ids it returned
@@ -4609,6 +4621,7 @@ export declare class Store {
4609
4621
  enqueueEpisode(prefix: string, notification: Omit<NotificationInput, "dedupeKey">, suffix: string, now: Date): boolean;
4610
4622
  /** Close every open episode under a prefix — the fault is provably gone. */
4611
4623
  resolveEpisodes(prefix: string, now: Date): number;
4624
+ notificationById(id: number): Notification | null;
4612
4625
  notificationsAfter(id: number, limit?: number): Notification[];
4613
4626
  listNotifications(only?: "pending" | "all"): Notification[];
4614
4627
  recordDelivery(id: number, outcome: {
@@ -7281,7 +7294,7 @@ export declare class Store {
7281
7294
  * under a claim that lapsed (a crash mid-turn), and not deferred past now.
7282
7295
  * One at a time per bot — the mate runs one turn per approver anyway.
7283
7296
  */
7284
- claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date): TelegramConversation | null;
7297
+ claimTelegramConversation(botId: string, owner: string, ttlMs: number, now: Date, only?: "turns" | "replies"): TelegramConversation | null;
7285
7298
  /** Extend a held claim; false means it lapsed and somebody else may hold the row. */
7286
7299
  renewTelegramConversation(id: number, owner: string, ttlMs: number, now: Date): boolean;
7287
7300
  /**
@@ -7308,6 +7321,20 @@ export declare class Store {
7308
7321
  outcome: string;
7309
7322
  nextAttemptAt: string;
7310
7323
  }, now: Date): boolean;
7324
+ /**
7325
+ * Replies the assistant wrote that still have not reached the person
7326
+ * `since` ago or longer (status and the console show them with a Retry):
7327
+ * the message's row, whose it is, when the oldest unsent part was
7328
+ * written, and the last send error.
7329
+ */
7330
+ unsentTelegramReplies(botId: string | null, approver: string | null, since: Date): {
7331
+ conversation: number;
7332
+ approver: string;
7333
+ since: string;
7334
+ error: string | null;
7335
+ }[];
7336
+ /** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
7337
+ retryTelegramReplies(approver: string | null, now: Date): number;
7311
7338
  /**
7312
7339
  * Persist the whole outbound half of a turn — every reply part and every
7313
7340
  * card, with the cards' tokens already minted — in one transaction, BEFORE
@@ -7477,6 +7504,12 @@ export declare class Store {
7477
7504
  * and the evening digest keep every fact either way.
7478
7505
  */
7479
7506
  pingAllowed(row: Pick<Notification, "id" | "kind" | "recipient" | "project" | "taskRef">, account: string): boolean;
7507
+ /** Who last completed or cancelled this task, when that is still its latest act: null otherwise. A revision's
7508
+ * completion is recorded on its root, so an act on a task it revises counts too, but only one made after this
7509
+ * revision was filed: settling older work never silences a newer revision, whose failures still reach people. */
7510
+ settledBy(taskRef: number): string | null;
7511
+ /** A release check, a replaced task, or one a revision superseded: its facts stay in the console. */
7512
+ neverPings(taskRef: number): boolean;
7480
7513
  /** The lead's work, which messages nobody: unless it is a security alert or a failure with nothing left for the lead. */
7481
7514
  leadQuiet(row: Pick<Notification, "id" | "kind" | "taskRef">, actor?: Actor | null): boolean;
7482
7515
  /** A failure with nothing left for the lead to do: its attempts are spent, or the task has failed for good. */
@@ -7492,6 +7525,11 @@ export declare class Store {
7492
7525
  } | null;
7493
7526
  /** Task-bound facts recorded since an instant, newest last (bounded): what an evening digest reads. */
7494
7527
  taskFactsSince(since: string, limit?: number): Notification[];
7528
+ /** The one message this update joins: the destination's batch opened within `windowMs`, or a new one. A task
7529
+ * has one line in it, saying its newest update (`notification`; null for a finished result). */
7530
+ chatBatchFor(destination: string, taskRef: number, run: number | null, now: Date, windowMs: number, notification?: number | null): ChatBatch;
7531
+ chatBatch(id: number): ChatBatch | null;
7532
+ setChatBatchMessage(id: number, message: string, digest: string): void;
7495
7533
  /** The message now showing this card, and what it shows. */
7496
7534
  setChatCardMessage(id: number, message: string, digest: string): void;
7497
7535
  markTelegramDigestSent(now: Date): void;