toolroll 0.9.10 → 0.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.11 — 2026-10-02
4
+
5
+ - **The Telegram bot always answers.** Replies and notifications go out from
6
+ the service itself, never from inside a build. If it can't answer, it says
7
+ why in plain words, and a session mix-up starts a fresh conversation instead
8
+ of refusing.
9
+
3
10
  ## 0.9.10 — 2026-10-02
4
11
 
5
12
  - **Chat reads like a teammate.** Short messages, outcome first, with real
@@ -12,18 +12,18 @@ export type ResolvedMate = {
12
12
  };
13
13
  } | {
14
14
  ok: false;
15
- reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "session-mismatch" | "thread-mismatch";
15
+ reason: "unpaired" | "unconfigured" | "direct-api" | "no-projects" | "busy" | "no-session";
16
16
  said: string | null;
17
17
  };
18
18
  /**
19
19
  * The session and thread a paired phone speaks in: the approver's live
20
20
  * session when it is compatible (same generation, same membership
21
- * credential, same ceiling), else one minted under the very terms the
22
- * console and CLI state for membership chat (no dollar ceiling, this
23
- * ceiling digest) — and only when NO session is live, because minting
24
- * ends the previous one and an unrelated console conversation is not
25
- * this phone's to end. The same rule for the thread: reused when its
26
- * ceiling matches, never closed from here.
21
+ * credential, same ceiling), else a fresh one minted under the very terms
22
+ * the console and CLI state for membership chat (no dollar ceiling, this
23
+ * ceiling digest). An incompatible session or thread left open on the
24
+ * computer is replaced rather than refused, so a message from the phone
25
+ * always gets an answer — except while a turn is still running there,
26
+ * which is "busy": the message waits its turn instead of ending it.
27
27
  */
28
28
  export declare function resolveChannelMate(store: Store, binding: {
29
29
  approver: string;
@@ -34,6 +34,8 @@ export declare function resolveChannelMate(store: Store, binding: {
34
34
  * chat as well, so the task shows everything asked of it wherever it was
35
35
  * asked. Best effort: the phone conversation never depends on it. */
36
36
  export declare function mirrorToTaskChat(store: Store, who: VerifiedApprover, taskId: string, surface: string, message: string, reply: string, now: Date): void;
37
+ /** The one short message a person gets when their question could not be answered. */
38
+ export declare function couldNotAnswerText(reason: string): string;
37
39
  export declare function tooLongText(length: number): string;
38
40
  /** A reply to a message about several tasks is never guessed: it names them and says how to choose. */
39
41
  export declare function whichTaskText(titles: readonly string[]): string;
@@ -25,12 +25,12 @@ export function channelRepos(store, approver, registry) {
25
25
  /**
26
26
  * The session and thread a paired phone speaks in: the approver's live
27
27
  * session when it is compatible (same generation, same membership
28
- * credential, same ceiling), else one minted under the very terms the
29
- * console and CLI state for membership chat (no dollar ceiling, this
30
- * ceiling digest) — and only when NO session is live, because minting
31
- * ends the previous one and an unrelated console conversation is not
32
- * this phone's to end. The same rule for the thread: reused when its
33
- * ceiling matches, never closed from here.
28
+ * credential, same ceiling), else a fresh one minted under the very terms
29
+ * the console and CLI state for membership chat (no dollar ceiling, this
30
+ * ceiling digest). An incompatible session or thread left open on the
31
+ * computer is replaced rather than refused, so a message from the phone
32
+ * always gets an answer — except while a turn is still running there,
33
+ * which is "busy": the message waits its turn instead of ending it.
34
34
  */
35
35
  export function resolveChannelMate(store, binding, repos, now) {
36
36
  const config = store.getChatConfig();
@@ -60,18 +60,17 @@ export function resolveChannelMate(store, binding, repos, now) {
60
60
  return { ok: false, reason: "unpaired", said: null };
61
61
  const who = verified.who;
62
62
  const credentialKey = subscriptionCredentialKey(config.provider);
63
- const mismatch = "Your open chat session on the computer covers different projects or another provider. Finish or end it there, then message here again. Nothing was changed.";
64
63
  let session = store.activeMateSession(who.name);
65
- if (session !== null &&
64
+ const liveThread = store.liveMateThreadFor(who.name);
65
+ const sessionMismatch = session !== null &&
66
66
  (session.approverGeneration !== who.generation ||
67
67
  session.credentialKey !== credentialKey ||
68
- session.ceilingDigest !== who.ceilingDigest)) {
69
- return { ok: false, reason: "session-mismatch", said: mismatch };
70
- }
71
- const liveThread = store.liveMateThreadFor(who.name);
72
- if (liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest)
73
- return { ok: false, reason: "thread-mismatch", said: mismatch };
74
- if (session === null) {
68
+ session.ceilingDigest !== who.ceilingDigest);
69
+ const threadMismatch = liveThread !== null && liveThread.ceilingDigest !== who.ceilingDigest;
70
+ // Replacing either would end a turn still running on the computer.
71
+ if ((sessionMismatch || threadMismatch) && store.liveMateTurnFor(who.name) !== null)
72
+ return { ok: false, reason: "busy", said: null };
73
+ if (session === null || sessionMismatch) {
75
74
  const termsDigest = createHash("sha256")
76
75
  .update(`0\n${who.ceilingDigest}`)
77
76
  .digest("hex");
@@ -85,9 +84,12 @@ export function resolveChannelMate(store, binding, repos, now) {
85
84
  }, now);
86
85
  session = store.getMateSession(id);
87
86
  if (session === null)
88
- return { ok: false, reason: "session-mismatch", said: mismatch };
87
+ return { ok: false, reason: "no-session", said: couldNotAnswerText("a new chat session couldn't be started") };
89
88
  }
90
- const thread = liveThread ?? store.openMateThread(who.name, who.ceilingDigest, now).thread;
89
+ // openMateThread closes a thread kept under another ceiling.
90
+ const thread = threadMismatch || liveThread === null
91
+ ? store.openMateThread(who.name, who.ceilingDigest, now).thread
92
+ : liveThread;
91
93
  return {
92
94
  ok: true,
93
95
  who,
@@ -113,6 +115,10 @@ export function mirrorToTaskChat(store, who, taskId, surface, message, reply, no
113
115
  // The task chat is a copy; the phone's own conversation already has it.
114
116
  }
115
117
  }
118
+ /** The one short message a person gets when their question could not be answered. */
119
+ export function couldNotAnswerText(reason) {
120
+ return `I couldn't answer that just now: ${reason}. Ask again, or open the console.`;
121
+ }
116
122
  export function tooLongText(length) {
117
123
  return `That message is ${length.toLocaleString("en-US")} characters; chat takes up to ${MATE_MESSAGE_MAX_CHARS.toLocaleString("en-US")}. Nothing was sent to the assistant. Send it in shorter parts, or say which part matters most.`;
118
124
  }
@@ -202,6 +202,10 @@ export async function processChatEvent(options) {
202
202
  return true;
203
203
  }
204
204
  const resolved = resolveChannelMate(store, { approver: binding.approver, approverGeneration: binding.generation }, repos, now);
205
+ if (!resolved.ok && resolved.reason === "busy") {
206
+ state.defer(event.id, "Assistant is answering another message", new Date(now.getTime() + 5000));
207
+ return true;
208
+ }
205
209
  if (!resolved.ok) {
206
210
  state.plan(event.id, [
207
211
  {
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";
@@ -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;
@@ -7366,38 +7412,13 @@ async function runWatchLoop(args) {
7366
7412
  // moment they arrive, and answering bumps the wake sequence, so the very
7367
7413
  // loop below wakes and resumes the freed task — phone to build, seconds.
7368
7414
  // The poll lease keeps this the only live poller; a cron `bridge
7369
- // telegram` overlapping it loses the lease race and reports busy.
7370
- let follower = null;
7371
- const followSource = loadBotToken(process.env, context.telegramTokenFile);
7372
- if (followSource !== null) {
7373
- const transport = context.telegramTransport ?? createTransport(followSource.token);
7374
- const publicUrl = text(flags, "public-url");
7375
- // v98: with a public hooks address (Tailscale Funnel covers /hooks), Telegram pushes updates to it instead.
7376
- const pushUrl = telegramPushUrl(readHooksBase(dirname(context.databaseFile)));
7377
- follower = followBridge(store, {
7378
- push: pushUrl === null ? null : { url: pushUrl, secret: telegramHookSecret(dirname(context.databaseFile), true) },
7379
- readProjects: telegramReadProjects(context),
7380
- canDeliver: telegramCanDeliver(context, followSource.token),
7381
- conversation: telegramConversation(context, publicUrl === undefined ? {} : { serverOrigin: publicUrl }),
7382
- botId: followSource.botId,
7383
- transport,
7384
- signal: followController.signal,
7385
- onCycle: cycle => {
7386
- // progress(), not write(): under --json this line went to stdout
7387
- // BESIDE the final envelope — the one stdout contamination in the
7388
- // watch (round-4 finding 12).
7389
- progress(`watch: bridge sent ${cycle.sent}, answered ${cycle.answered}, paired ${cycle.paired}` +
7390
- ((cycle.statusReplies ?? 0) > 0 ? `, status replies ${cycle.statusReplies}` : "") +
7391
- ((cycle.chatQueued ?? 0) > 0 ? `, chat received ${cycle.chatQueued}` : "") +
7392
- ((cycle.chatAnswered ?? 0) > 0 ? `, chat replied ${cycle.chatAnswered}` : "") +
7393
- ((cycle.chatConfirmed ?? 0) > 0 ? `, chat confirmed ${cycle.chatConfirmed}` : "") +
7394
- (cycle.problems.length > 0 ? ` — ${cycle.problems.length} problem(s): ${cycle.problems[0].slice(0, 200)}` : ""));
7395
- },
7396
- }).catch(error => {
7397
- progress(`watch: the telegram follower died — ${describe(error)}; taps wait for the next watch`);
7398
- return null;
7399
- });
7400
- }
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" } });
7401
7422
  const slackFollower = followSlack({ store, dir: dirname(context.databaseFile), signal: followController.signal,
7402
7423
  readProjects: telegramReadProjects(context), evidenceRoot: context.evidenceRoot,
7403
7424
  ...(context.mateSeams?.subscriptionRunner ? { subscriptionRunner: context.mateSeams.subscriptionRunner } : {}),
@@ -7690,7 +7711,7 @@ async function runWatchLoop(args) {
7690
7711
  // pass is the fallback shape for a watch started before a token existed.
7691
7712
  if (follower === null && now - lastBridge >= bridgeEveryMs) {
7692
7713
  lastBridge = now;
7693
- const source = loadBotToken(process.env, context.telegramTokenFile);
7714
+ const source = serviceBridge ? null : loadBotToken(process.env, context.telegramTokenFile);
7694
7715
  const dir = dirname(context.databaseFile);
7695
7716
  // Exactly ONE service carries the pages — the chosen primary, or
7696
7717
  // the sensible implicit one. Telegram keeps draining taps and
@@ -8538,6 +8559,7 @@ async function upCommand(flags, context) {
8538
8559
  onFollowController: controller => followControllers.push(controller),
8539
8560
  containmentLines,
8540
8561
  passes: projectPasses,
8562
+ serviceBridge: true,
8541
8563
  onReady: () => {
8542
8564
  markReady();
8543
8565
  if (bind)
@@ -8557,6 +8579,33 @@ async function upCommand(flags, context) {
8557
8579
  await ready;
8558
8580
  };
8559
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
+ })();
8560
8609
  const dynamicCeiling = resolveCeiling(repos, projectRoots).ceiling;
8561
8610
  const rejected = new Set();
8562
8611
  const registrySupervisor = (async () => {
@@ -8691,6 +8740,7 @@ async function upCommand(flags, context) {
8691
8740
  await stopped;
8692
8741
  await registrySupervisor;
8693
8742
  await Promise.all([...loopResults.values()]);
8743
+ await telegramSupervisor;
8694
8744
  clearInterval(runnerHeartbeat);
8695
8745
  if (runTimer !== undefined)
8696
8746
  clearTimeout(runTimer);
@@ -8738,6 +8788,7 @@ async function upCommand(flags, context) {
8738
8788
  * bridge telegram token [<bot-token>|--clear] set the credential file
8739
8789
  * bridge telegram status
8740
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
8741
8792
  *
8742
8793
  * The bot token comes from ${TOKEN_ENV} or the credential file this command
8743
8794
  * writes (0600, beside the database). Cron the pass right after tick; a
@@ -8754,7 +8805,14 @@ async function bridgeCommand(positional, flags, context) {
8754
8805
  return demoFence;
8755
8806
  const [channel, action] = positional;
8756
8807
  if (channel !== "telegram") {
8757
- 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
+ ]);
8758
8816
  }
8759
8817
  if (action === "token") {
8760
8818
  const value = positional[2];
@@ -8871,6 +8929,9 @@ async function bridgeCommand(positional, flags, context) {
8871
8929
  if (source === null) {
8872
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);
8873
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);
8874
8935
  const transport = context.telegramTransport ?? createTransport(source.token);
8875
8936
  // --follow: stay on the wire. One long-poll actor holds the poll lease;
8876
8937
  // an answer tapped on a phone lands in seconds instead of at the next
@@ -9588,7 +9649,18 @@ async function statusCommand(positional, flags, context) {
9588
9649
  const projects = projectBuildsStatus(context);
9589
9650
  const projectsLine = projects.length === 0 ? null
9590
9651
  : `Builds by project: ${projects.slice(0, 8).map(one => `${one.name} ${one.running} of ${one.limit}`).join(", ")}${projects.length > 8 ? ", …" : ""}`;
9591
- 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`;
9592
9664
  }
9593
9665
  /** Each known project's running builds against its limit: its own number, never past its workers' capacity. */
9594
9666
  function projectBuildsStatus(context) {
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
package/dist/store.d.ts CHANGED
@@ -7294,7 +7294,7 @@ export declare class Store {
7294
7294
  * under a claim that lapsed (a crash mid-turn), and not deferred past now.
7295
7295
  * One at a time per bot — the mate runs one turn per approver anyway.
7296
7296
  */
7297
- 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;
7298
7298
  /** Extend a held claim; false means it lapsed and somebody else may hold the row. */
7299
7299
  renewTelegramConversation(id: number, owner: string, ttlMs: number, now: Date): boolean;
7300
7300
  /**
@@ -7321,6 +7321,20 @@ export declare class Store {
7321
7321
  outcome: string;
7322
7322
  nextAttemptAt: string;
7323
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;
7324
7338
  /**
7325
7339
  * Persist the whole outbound half of a turn — every reply part and every
7326
7340
  * card, with the cards' tokens already minted — in one transaction, BEFORE
package/dist/store.js CHANGED
@@ -21621,14 +21621,18 @@ export class Store {
21621
21621
  * under a claim that lapsed (a crash mid-turn), and not deferred past now.
21622
21622
  * One at a time per bot — the mate runs one turn per approver anyway.
21623
21623
  */
21624
- claimTelegramConversation(botId, owner, ttlMs, now) {
21624
+ claimTelegramConversation(botId, owner, ttlMs, now, only) {
21625
21625
  return this.transact(() => {
21626
21626
  const stamp = now.toISOString();
21627
+ // "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
21628
+ const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
21629
+ const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
21627
21630
  const row = this.db
21628
21631
  .prepare(`SELECT * FROM telegram_conversation
21629
21632
  WHERE bot_id = ? AND state IN ('queued','running')
21630
21633
  AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
21631
21634
  AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
21635
+ ${which}
21632
21636
  ORDER BY id LIMIT 1`)
21633
21637
  .get(botId, stamp, stamp);
21634
21638
  if (row === undefined)
@@ -21686,6 +21690,31 @@ export class Store {
21686
21690
  .run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
21687
21691
  return Number(changes) === 1;
21688
21692
  }
21693
+ /**
21694
+ * Replies the assistant wrote that still have not reached the person
21695
+ * `since` ago or longer (status and the console show them with a Retry):
21696
+ * the message's row, whose it is, when the oldest unsent part was
21697
+ * written, and the last send error.
21698
+ */
21699
+ unsentTelegramReplies(botId, approver, since) {
21700
+ return this.db
21701
+ .prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
21702
+ (SELECT q.last_error FROM telegram_conversation_part q WHERE q.conversation = c.id AND q.state = 'pending' ORDER BY q.ordinal LIMIT 1) AS error
21703
+ FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
21704
+ WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
21705
+ GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
21706
+ .all(botId, botId, approver, approver, since.toISOString())
21707
+ .map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
21708
+ }
21709
+ /** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
21710
+ retryTelegramReplies(approver, now) {
21711
+ const { changes } = this.db
21712
+ .prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
21713
+ WHERE state = 'queued' AND (? IS NULL OR approver = ?)
21714
+ AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
21715
+ .run(now.toISOString(), approver, approver);
21716
+ return Number(changes);
21717
+ }
21689
21718
  // ---- the outbound parts (v63) ------------------------------------------------
21690
21719
  /**
21691
21720
  * Persist the whole outbound half of a turn — every reply part and every
@@ -122,6 +122,12 @@ export declare function splitParts(text: string): string[];
122
122
  * outside any SQLite transaction. The caller holds the poll lease and
123
123
  * renews it; the row's own claim is renewed here. Bounded per call so a
124
124
  * flood cannot pin one pass forever.
125
+ *
126
+ * `only` splits the work for the follower: "turns" runs the model and
127
+ * persists the reply but leaves the sending to the bridge's own cycle,
128
+ * which calls back with "replies" — so a reply goes out from the same
129
+ * place, in the same order and over the same connection as notifications,
130
+ * never beside a long poll. Absent, a row is answered and sent in one go.
125
131
  */
126
132
  export declare function processTelegramConversations(args: {
127
133
  store: Store;
@@ -134,4 +140,5 @@ export declare function processTelegramConversations(args: {
134
140
  report: ConversationReport;
135
141
  signal?: AbortSignal;
136
142
  limit?: number;
143
+ only?: "turns" | "replies";
137
144
  }): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import { CHAT_ACTIONS, sharedActionAllowsChallenge, sharedActionPayload } from "./chat-actions.js";
2
- import { taskInCeiling, mirrorToTaskChat, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
2
+ import { taskInCeiling, mirrorToTaskChat, couldNotAnswerText, channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText } from "./chat-channel.js";
3
3
  export { channelRepos as telegramConversationRepos, resolveChannelMate as resolveTelegramMate, tooLongText, whichTaskText, replyContextFor, NO_PHONE_LINK, NO_TASK_LINK, proposalLink, confirmedLink, proposalPreview, proposalOutcomeText, handoffCardText, confirmedCardText, CHAT_ACTION_PARITY as TELEGRAM_ACTION_PARITY, parityGaps } from "./chat-channel.js";
4
4
  /**
5
5
  * The paired phone as one more way to use the same assistant.
@@ -299,11 +299,17 @@ export function splitParts(text) {
299
299
  * outside any SQLite transaction. The caller holds the poll lease and
300
300
  * renews it; the row's own claim is renewed here. Bounded per call so a
301
301
  * flood cannot pin one pass forever.
302
+ *
303
+ * `only` splits the work for the follower: "turns" runs the model and
304
+ * persists the reply but leaves the sending to the bridge's own cycle,
305
+ * which calls back with "replies" — so a reply goes out from the same
306
+ * place, in the same order and over the same connection as notifications,
307
+ * never beside a long poll. Absent, a row is answered and sent in one go.
302
308
  */
303
309
  export async function processTelegramConversations(args) {
304
310
  const limit = args.limit ?? 20;
305
311
  for (let count = 0; count < limit && args.signal?.aborted !== true; count++) {
306
- const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock());
312
+ const row = args.store.claimTelegramConversation(args.botId, args.owner, CONVERSATION_CLAIM_MS, args.clock(), args.only);
307
313
  if (row === null)
308
314
  return;
309
315
  try {
@@ -318,6 +324,8 @@ export async function processTelegramConversations(args) {
318
324
  }
319
325
  class PlanRefused extends Error {
320
326
  }
327
+ /** The outcome a planned reply waits under until the bridge's cycle sends it. */
328
+ const ANSWERED_SENDING = "answered:sending";
321
329
  /**
322
330
  * One claimed message, in order of what is already known about it:
323
331
  *
@@ -334,12 +342,13 @@ class PlanRefused extends Error {
334
342
  * the ceiling the turn opened under; only a confirmed message id counts.
335
343
  */
336
344
  async function runTelegramConversation(row, args) {
337
- const { store, botId, transport, owner, clock, readProjects, options, report } = args;
345
+ const { store, botId, transport, owner, clock, readProjects, options, report, only } = args;
338
346
  const finish = (result) => store.finishTelegramConversation(row.id, owner, result, clock());
339
347
  const held = () => store.renewTelegramConversation(row.id, owner, CONVERSATION_CLAIM_MS, clock());
340
348
  const ageMs = clock().getTime() - Date.parse(row.createdAt);
341
- const requeue = (outcome) => {
349
+ const requeue = async (outcome) => {
342
350
  if (ageMs > CONVERSATION_MAX_AGE_MS) {
351
+ await notify(couldNotAnswerText(outcome === "registry-unavailable" ? "your projects couldn't be read" : "the assistant stayed busy with another message"));
343
352
  finish({ state: "failed", outcome: `${outcome}:gave-up` });
344
353
  report.refused++;
345
354
  return;
@@ -384,6 +393,7 @@ async function runTelegramConversation(row, args) {
384
393
  const bound = store.getTelegramConversation(row.id)?.session ?? null;
385
394
  const session = bound === null ? null : store.getMateSession(bound);
386
395
  if (session === null) {
396
+ await notify(couldNotAnswerText("the chat session for this reply is gone"));
387
397
  finish({ state: "failed", outcome: "unsent:no-session" });
388
398
  report.refused++;
389
399
  return;
@@ -407,6 +417,7 @@ async function runTelegramConversation(row, args) {
407
417
  }
408
418
  if (now.getTime() - Date.parse(row.createdAt) > DELIVERY_MAX_AGE_MS) {
409
419
  report.problems.push(`telegram chat reply for update ${row.updateId} was not sent within a day: ${part.lastError ?? "unsent"}`);
420
+ await notify(couldNotAnswerText("my reply couldn't be sent for a day"));
410
421
  finish({ state: "failed", outcome: `unsent:gave-up:${part.lastError ?? "unsent"}` });
411
422
  report.refused++;
412
423
  return;
@@ -512,15 +523,24 @@ async function runTelegramConversation(row, args) {
512
523
  ...(keyboard === undefined ? {} : { reply_markup: { inline_keyboard: keyboard } }),
513
524
  });
514
525
  }
526
+ // The first time the answer itself fails to go out, the person hears so once,
527
+ // in plain words; the reply itself keeps being retried and shows as
528
+ // unsent in status and the console after two minutes.
529
+ const firstFailure = part.kind === "reply" && store.listTelegramConversationParts(row.id).every(one => one.attempts <= (one.state === "sent" ? 1 : 0));
530
+ const failed = async (until, error, uncertain) => {
531
+ if (!store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain, retryAt: until.toISOString() }, clock()))
532
+ return;
533
+ if (firstFailure)
534
+ await notify(couldNotAnswerText(uncertain ? "my reply may not have reached Telegram" : "Telegram didn't accept my reply"));
535
+ defer(until, error);
536
+ };
515
537
  let answer;
516
538
  try {
517
539
  answer = await send();
518
540
  }
519
541
  catch {
520
542
  // The answer was lost: Telegram may or may not have the message. Said so, retried, counted.
521
- const error = "Telegram transport failed; delivery may be uncertain";
522
- if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
523
- defer(backoff, error);
543
+ await failed(backoff, "Telegram transport failed; delivery may be uncertain", true);
524
544
  return;
525
545
  }
526
546
  if (!answer.ok) {
@@ -535,15 +555,12 @@ async function runTelegramConversation(row, args) {
535
555
  if (paused > until)
536
556
  until = paused;
537
557
  }
538
- if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain, retryAt: until.toISOString() }, clock()))
539
- defer(until, error);
558
+ await failed(until, error, uncertain);
540
559
  return;
541
560
  }
542
561
  const id = answer.result?.message_id;
543
562
  if (!Number.isSafeInteger(id) || id <= 0) {
544
- const error = "Telegram returned no confirmed message identity";
545
- if (store.settleTelegramConversationPart(row.id, part.ordinal, owner, { ok: false, error, uncertain: true, retryAt: backoff.toISOString() }, clock()))
546
- defer(backoff, error);
563
+ await failed(backoff, "Telegram returned no confirmed message identity", true);
547
564
  return;
548
565
  }
549
566
  const messageId = String(id);
@@ -596,11 +613,11 @@ async function runTelegramConversation(row, args) {
596
613
  }
597
614
  store.bindTelegramConversationTurn(row.id, owner, session, turnId);
598
615
  if (turn.state === "queued" || turn.state === "running") {
599
- requeue("replayed:running");
616
+ await requeue("replayed:running");
600
617
  return;
601
618
  }
602
619
  if (turn.state !== "answered") {
603
- await notify(`Your earlier message was received, but the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)}). Nothing was changed. Send it again if you still want it.`);
620
+ await notify(couldNotAnswerText(`the assistant's reply did not complete (${phoneText(turn.failureReason ?? "unknown", 40)})`));
604
621
  finish({ state: "failed", outcome: `replayed:${turn.failureReason ?? "failed"}` });
605
622
  report.refused++;
606
623
  return;
@@ -609,11 +626,19 @@ async function runTelegramConversation(row, args) {
609
626
  const proposals = store.listMateProposals(turn.thread, ["pending"]).filter(one => one.turn === turn.id);
610
627
  if (!plan(session, turnId, reply, proposals, repos) && store.listTelegramConversationParts(row.id).length === 0)
611
628
  return;
612
- await deliver("replayed");
629
+ await sendOrHandOff("replayed");
630
+ };
631
+ /** Send now, or — for the follower's turn worker — leave it to the bridge's cycle, which sends it next. */
632
+ const sendOrHandOff = async (outcomeWord) => {
633
+ if (only !== "turns") {
634
+ await deliver(outcomeWord);
635
+ return;
636
+ }
637
+ finish({ state: "queued", outcome: outcomeWord === "answered" ? ANSWERED_SENDING : "delivering", nextAttemptAt: clock().toISOString() });
613
638
  };
614
639
  // 2. Parts already planned: the turn answered; only the sending remains.
615
640
  if (store.listTelegramConversationParts(row.id).length > 0) {
616
- await deliver("replayed");
641
+ await deliver(row.outcome === ANSWERED_SENDING ? "answered" : "replayed");
617
642
  return;
618
643
  }
619
644
  // 3. The ceiling, reloaded now.
@@ -623,7 +648,7 @@ async function runTelegramConversation(row, args) {
623
648
  }
624
649
  catch {
625
650
  report.problems.push("telegram chat could not read the current project records");
626
- requeue("registry-unavailable");
651
+ await requeue("registry-unavailable");
627
652
  return;
628
653
  }
629
654
  const repos = telegramConversationRepos(store, binding.approver, registry);
@@ -638,6 +663,10 @@ async function runTelegramConversation(row, args) {
638
663
  }
639
664
  // 5. The session and thread — shared with the console and the CLI.
640
665
  const resolved = resolveTelegramMate(store, binding, repos, clock());
666
+ if (!resolved.ok && resolved.reason === "busy") {
667
+ await requeue("busy");
668
+ return;
669
+ }
641
670
  if (!resolved.ok) {
642
671
  if (resolved.said !== null)
643
672
  await notify(resolved.said);
@@ -676,10 +705,10 @@ async function runTelegramConversation(row, args) {
676
705
  store.bindTelegramConversationTurn(row.id, owner, session.id, receipt.turn);
677
706
  if (!outcome.ok && "refused" in outcome) {
678
707
  if (outcome.refused === "concurrent") {
679
- requeue("busy");
708
+ await requeue("busy");
680
709
  return;
681
710
  }
682
- await notify(`${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}. Nothing was changed.`);
711
+ await notify(phoneText(couldNotAnswerText(outcome.message), 1_000));
683
712
  finish({ state: "failed", outcome: `refused:${outcome.refused}` });
684
713
  report.refused++;
685
714
  return;
@@ -687,7 +716,7 @@ async function runTelegramConversation(row, args) {
687
716
  if (!outcome.ok) {
688
717
  // Truthful: what failed, that nothing was kept, and that a new message
689
718
  // is a new turn. A failed turn's drafts are already gone.
690
- const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : `The assistant's reply did not complete: ${outcome.message}. Nothing it proposed was kept. Send your message again if you still want it.`;
719
+ const words = outcome.failed === "revoked" ? `${outcome.message.charAt(0).toUpperCase()}${outcome.message.slice(1)}.` : couldNotAnswerText(`the assistant's reply did not complete (${outcome.message})`);
691
720
  await notify(phoneText(words, 1_000));
692
721
  finish({ state: "failed", outcome: `failed:${outcome.failed}` });
693
722
  report.refused++;
@@ -703,5 +732,5 @@ async function runTelegramConversation(row, args) {
703
732
  const proposals = store.listMateProposals(thread.id, ["pending"]).filter(one => one.turn === outcome.turn);
704
733
  if (!plan(session.id, outcome.turn, outcome.reply, proposals, who.repos) && store.listTelegramConversationParts(row.id).length === 0)
705
734
  return;
706
- await deliver("answered");
735
+ await sendOrHandOff("answered");
707
736
  }
@@ -3,7 +3,10 @@
3
3
  * the settings page; this card only mints or revokes the signed-in person's
4
4
  * pairing, never anyone else's. */
5
5
  import type { Store } from "./store.js";
6
+ /** How long a written reply may wait to be sent before it shows here with a Retry. */
7
+ export declare const UNSENT_REPLY_MS: number;
6
8
  export declare function telegramSettingsHtml(store: Store, botId: string | null, who: string, csrf: string, options?: {
7
9
  code?: string;
8
10
  problem?: string;
11
+ now?: Date;
9
12
  }): string;
@@ -1,3 +1,5 @@
1
+ /** How long a written reply may wait to be sent before it shows here with a Retry. */
2
+ export const UNSENT_REPLY_MS = 2 * 60_000;
1
3
  const escape = (text) => text
2
4
  .replaceAll("&", "&amp;")
3
5
  .replaceAll("<", "&lt;")
@@ -17,6 +19,15 @@ export function telegramSettingsHtml(store, botId, who, csrf, options = {}) {
17
19
  const mine = bindings.filter(binding => binding.approver === who);
18
20
  const others = bindings.length - mine.length;
19
21
  content = `<p><strong>${mine.length > 0 ? "Your phone is paired." : "Your phone is not paired."}</strong> ${others === 0 ? "No teammates are paired yet." : `${others} teammate${others === 1 ? " is" : "s are"} paired.`}</p>`;
22
+ // A reply the assistant wrote that has not reached this person's phone for two minutes.
23
+ const unsent = store.unsentTelegramReplies(botId, who, new Date((options.now ?? new Date()).getTime() - UNSENT_REPLY_MS));
24
+ if (unsent.length > 0) {
25
+ const oldest = unsent[0];
26
+ content +=
27
+ `<div class="card" role="status" data-unsent-replies><p><strong>${unsent.length === 1 ? "A reply hasn't reached your phone" : `${unsent.length} replies haven't reached your phone`}</strong></p>` +
28
+ `<p class="meta">Trying since ${escape(oldest.since.slice(11, 16))} UTC${oldest.error === null ? "" : `: ${escape(oldest.error)}`}</p>` +
29
+ `<form method="post" action="/settings/telegram/retry">${hidden}<button type="submit">Retry</button></form></div>`;
30
+ }
20
31
  if (options.code !== undefined) {
21
32
  content +=
22
33
  "<h2>Pair your phone</h2><p>Send this to the bot in a private Telegram chat within 10 minutes:</p>" +
@@ -98,6 +98,20 @@ export type TelegramUpload = {
98
98
  bytes: Buffer;
99
99
  };
100
100
  export declare function createTransport(token: string, timeoutMs?: number): TelegramTransport;
101
+ /**
102
+ * Why this process can't reach Telegram the way the service does, or null.
103
+ * A coding agent's sandbox fences the network: Codex says so in its own
104
+ * variables, and Claude Code's sandbox routes traffic through a proxy on
105
+ * this computer, which curl uses and Node's fetch does not — so sends fail
106
+ * "fetch failed" while curl works (Oct 2). Builds, checks and agents also
107
+ * get a database of their own, so none of them can hold the live bridge;
108
+ * this keeps an agent-launched `watch` or `bridge` from holding it either.
109
+ */
110
+ export declare function networkFence(env: Record<string, string | undefined>): string | null;
111
+ /** The plain line a fenced process says instead of polling or sending. */
112
+ export declare function networkFenceLine(reason: string): string;
113
+ /** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
114
+ export declare function transportError(error: unknown): string;
101
115
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
102
116
  export declare function multipartOf(params: Record<string, unknown>, upload: TelegramUpload): FormData;
103
117
  export declare function hashPairingCode(code: string): string;
package/dist/telegram.js CHANGED
@@ -194,7 +194,7 @@ export function createTransport(token, timeoutMs = 30_000) {
194
194
  catch (error) {
195
195
  return {
196
196
  ok: false,
197
- description: scrub(error instanceof Error ? error.message : String(error), token),
197
+ description: scrub(transportError(error), token),
198
198
  uncertain: true,
199
199
  };
200
200
  }
@@ -204,6 +204,48 @@ export function createTransport(token, timeoutMs = 30_000) {
204
204
  }
205
205
  };
206
206
  }
207
+ /**
208
+ * Why this process can't reach Telegram the way the service does, or null.
209
+ * A coding agent's sandbox fences the network: Codex says so in its own
210
+ * variables, and Claude Code's sandbox routes traffic through a proxy on
211
+ * this computer, which curl uses and Node's fetch does not — so sends fail
212
+ * "fetch failed" while curl works (Oct 2). Builds, checks and agents also
213
+ * get a database of their own, so none of them can hold the live bridge;
214
+ * this keeps an agent-launched `watch` or `bridge` from holding it either.
215
+ */
216
+ export function networkFence(env) {
217
+ const set = (name) => (env[name] ?? "") !== "";
218
+ if (set("CODEX_SANDBOX_NETWORK_DISABLED") || set("CODEX_SANDBOX"))
219
+ return "a Codex sandbox";
220
+ const agent = ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"].some(set);
221
+ const loopbackProxy = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"].some(name => {
222
+ const value = env[name] ?? "";
223
+ if (value === "")
224
+ return false;
225
+ try {
226
+ return /^(?:localhost|127\.\d+\.\d+\.\d+|\[::1\])$/i.test(new URL(value.includes("://") ? value : `http://${value}`).hostname);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ });
232
+ return agent && loopbackProxy ? "a Claude Code sandbox" : null;
233
+ }
234
+ /** The plain line a fenced process says instead of polling or sending. */
235
+ export function networkFenceLine(reason) {
236
+ return `Telegram: not connecting from here — this process runs inside ${reason}, which blocks its network. Replies and notifications go out from the Toolroll service.`;
237
+ }
238
+ /** fetch's own message ("fetch failed") with the reason under it, e.g. "fetch failed (EMFILE: too many open files)". */
239
+ export function transportError(error) {
240
+ const message = error instanceof Error ? error.message : String(error);
241
+ const cause = error instanceof Error ? error.cause : undefined;
242
+ if (cause === undefined || cause === null)
243
+ return message;
244
+ const code = typeof cause.code === "string" ? cause.code : null;
245
+ const detail = cause instanceof Error ? cause.message : String(cause);
246
+ const why = code !== null && !detail.includes(code) ? `${code}: ${detail}` : detail;
247
+ return why === "" || why === message ? message : `${message} (${why.slice(0, 160)})`;
248
+ }
207
249
  /** The multipart body: every param a field (objects as JSON), the file last. Exported for the adapter's own test only. */
208
250
  export function multipartOf(params, upload) {
209
251
  const form = new FormData();
@@ -332,12 +374,16 @@ export async function followBridge(store, options) {
332
374
  // turn must not stall the long poll, and each cycle's lease re-acquire is
333
375
  // the renewal that keeps this the only live poller meanwhile. One
334
376
  // processor at a time; its counts land on the totals when it finishes.
377
+ // It only persists the reply: the cycle sends it, in the same slot as
378
+ // notifications, so a reply never needs a connection of its own beside
379
+ // the long poll (Oct 2: replies failed "fetch failed" for minutes while
380
+ // notifications from the same process went out).
335
381
  let inFlight = null;
336
382
  const kick = () => {
337
383
  if (inFlight !== null || options.conversation === undefined || options.readProjects === undefined)
338
384
  return;
339
385
  const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
340
- inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal)
386
+ inFlight = processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "turns")
341
387
  .catch(error => { report.problems.push(`telegram chat: ${error instanceof Error ? error.message : String(error)}`); })
342
388
  .finally(() => {
343
389
  inFlight = null;
@@ -449,13 +495,18 @@ export async function followBridge(store, options) {
449
495
  await deliverOutbox(store, botId, transport, owner, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin, options.conversation?.evidenceRoot);
450
496
  await deliverTeam(store, botId, transport, clock, report, options.readProjects, options.canDeliver, options.conversation?.phoneOrigin);
451
497
  }
498
+ // Replies the turn worker has planned go out here, like notifications.
499
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
500
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, signal, "replies");
501
+ }
452
502
  // Pushed updates first (some may wait from before a fall back), then — only while
453
503
  // Telegram isn't pushing — ask for more.
454
504
  const pushed = await drainInbox({ store, botId, transport, clock, report, readProjects: options.readProjects, conversation: options.conversation, projects: null }, owner, lease.generation);
455
505
  if (pushed > 0)
456
506
  report.pushed = pushed;
457
507
  if (!pushing()) {
458
- await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, pollSeconds, signal, options.readProjects, options.conversation);
508
+ // While a turn is running, poll briefly so its reply is sent within a second or two of being written.
509
+ await drainUpdates(store, botId, transport, owner, lease.generation, lease.cursor, clock, report, inFlight === null ? pollSeconds : 1, signal, options.readProjects, options.conversation);
459
510
  // A push address nobody here wants any more (the public address was removed): take it down, so asking works again.
460
511
  if (report.webhookActive === true && push === null)
461
512
  await transport("deleteWebhook", { drop_pending_updates: false }, signal);
@@ -474,11 +525,13 @@ export async function followBridge(store, options) {
474
525
  total.chatRefused = (total.chatRefused ?? 0) + report.chatRefused;
475
526
  if (report.chatConfirmed !== undefined)
476
527
  total.chatConfirmed = (total.chatConfirmed ?? 0) + report.chatConfirmed;
528
+ if (report.chatAnswered !== undefined)
529
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
477
530
  total.problems.push(...report.problems);
478
- if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
531
+ if (report.sent > 0 || report.answered > 0 || report.paired > 0 || (report.statusReplies ?? 0) > 0 || (report.chatQueued ?? 0) > 0 || (report.chatAnswered ?? 0) > 0 || (report.chatRefused ?? 0) > 0 || (report.chatConfirmed ?? 0) > 0 || report.problems.length > 0) {
479
532
  options.onCycle?.(report);
480
533
  }
481
- if (report.problems.length > 0 && report.sent === 0 && report.answered === 0) {
534
+ if (report.problems.length > 0 && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0) {
482
535
  // The wire is down. Back off; the counter resets on the first clean cycle.
483
536
  if (!signal.aborted) {
484
537
  await wait(FOLLOW_BACKOFF_MS[Math.min(failures, FOLLOW_BACKOFF_MS.length - 1)]);
@@ -493,7 +546,7 @@ export async function followBridge(store, options) {
493
546
  const took = Date.now() - startedAt;
494
547
  // While Telegram pushes (v98) there is no long poll to wait on: a pushed update waits at most this long.
495
548
  const floor = pushing() ? PUSH_IDLE_MS : FOLLOW_IDLE_FLOOR_MS;
496
- if (!signal.aborted && report.sent === 0 && report.answered === 0 && took < floor) {
549
+ if (!signal.aborted && report.sent === 0 && report.answered === 0 && (report.chatAnswered ?? 0) === 0 && took < floor) {
497
550
  await wait(floor - took);
498
551
  }
499
552
  }
@@ -501,16 +554,25 @@ export async function followBridge(store, options) {
501
554
  finally {
502
555
  // A turn in flight finishes on its own bounds (the engine's wall clock);
503
556
  // its reply is fenced on the claim and the channel like every other part.
504
- if (inFlight !== null)
557
+ if (inFlight !== null) {
505
558
  await inFlight;
559
+ // Its reply was written for the cycle that will not come: send it now, while the lease is still ours.
560
+ if (options.conversation !== undefined && options.readProjects !== undefined) {
561
+ const report = { sent: 0, answered: 0, paired: 0, ignored: 0, backlog: false, problems: [] };
562
+ await processConversations(store, botId, transport, owner, clock, report, options.readProjects, options.conversation, undefined, "replies").catch(() => { });
563
+ if (report.chatAnswered !== undefined)
564
+ total.chatAnswered = (total.chatAnswered ?? 0) + report.chatAnswered;
565
+ total.problems.push(...report.problems);
566
+ }
567
+ }
506
568
  store.releaseBridgeLease(botId, owner, clock());
507
569
  }
508
570
  return total;
509
571
  }
510
572
  /** The queued turns for one bot, counted onto the pass's report. */
511
- async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal) {
573
+ async function processConversations(store, botId, transport, owner, clock, report, readProjects, conversation, signal, only) {
512
574
  const chat = { answered: 0, refused: 0, problems: [] };
513
- await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }) });
575
+ await processTelegramConversations({ store, botId, transport, owner, clock, readProjects, options: conversation, report: chat, ...(signal === undefined ? {} : { signal }), ...(only === undefined ? {} : { only }) });
514
576
  if (chat.answered > 0)
515
577
  report.chatAnswered = (report.chatAnswered ?? 0) + chat.answered;
516
578
  if (chat.refused > 0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolroll",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "description": "A control plane for unattended coding agents — queue tasks, walk away, come back to pull requests. Agents build in leased worktrees, never touch your default branch, and interrupt you only for decisions that need a human.",
5
5
  "directories": {
6
6
  "doc": "docs"