toolroll 0.9.10 → 0.9.12

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.
Files changed (53) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/assignment.d.ts +3 -0
  3. package/dist/assignment.js +3 -1
  4. package/dist/browser/workspace.css +2 -2
  5. package/dist/browser/workspace.js +20 -18
  6. package/dist/browser-workspace.d.ts +61 -0
  7. package/dist/chat-channel.d.ts +9 -7
  8. package/dist/chat-channel.js +23 -17
  9. package/dist/chat-delivery.js +21 -0
  10. package/dist/chat-quiet.js +1 -0
  11. package/dist/chat-voice.d.ts +3 -1
  12. package/dist/chat-voice.js +13 -5
  13. package/dist/cli.d.ts +1 -1
  14. package/dist/cli.js +1 -0
  15. package/dist/coding-update.d.ts +13 -0
  16. package/dist/coding-update.js +68 -0
  17. package/dist/desktop-update.d.ts +7 -0
  18. package/dist/desktop-update.js +42 -1
  19. package/dist/flow-triggers.js +17 -1
  20. package/dist/guides.js +9 -0
  21. package/dist/lead-status.d.ts +10 -1
  22. package/dist/lead-status.js +13 -3
  23. package/dist/lead-voice.d.ts +81 -0
  24. package/dist/lead-voice.js +275 -0
  25. package/dist/needs-you.d.ts +7 -0
  26. package/dist/needs-you.js +9 -0
  27. package/dist/operate.d.ts +2 -0
  28. package/dist/operate.js +186 -38
  29. package/dist/serve.d.ts +5 -1
  30. package/dist/serve.js +447 -226
  31. package/dist/setup-guide.d.ts +2 -2
  32. package/dist/store.d.ts +15 -1
  33. package/dist/store.js +38 -1
  34. package/dist/surface.js +8 -1
  35. package/dist/task-status.d.ts +6 -0
  36. package/dist/task-status.js +19 -2
  37. package/dist/team-cli.js +1 -1
  38. package/dist/telegram-mate.d.ts +7 -0
  39. package/dist/telegram-mate.js +50 -21
  40. package/dist/telegram-progress.js +7 -0
  41. package/dist/telegram-settings.d.ts +3 -0
  42. package/dist/telegram-settings.js +11 -0
  43. package/dist/telegram.d.ts +14 -0
  44. package/dist/telegram.js +105 -12
  45. package/dist/test-temp.js +1 -1
  46. package/dist/toolroll-update.d.ts +3 -0
  47. package/dist/toolroll-update.js +13 -2
  48. package/dist/work-index.d.ts +9 -0
  49. package/dist/work-index.js +57 -20
  50. package/dist/work-summary.d.ts +4 -1
  51. package/dist/workspace-ui.d.ts +25 -0
  52. package/dist/workspace-ui.js +20 -1
  53. package/package.json +1 -1
@@ -15,10 +15,10 @@ export declare function detectPreparation(repo: string): {
15
15
  } | null;
16
16
  /** Browser installation may write only this known file inside its project. */
17
17
  export declare function previewProjectInstructions(repo: string): {
18
- content?: never;
19
18
  ok: false;
20
19
  message: string;
21
20
  plan?: never;
21
+ content?: never;
22
22
  fingerprint?: never;
23
23
  installed?: never;
24
24
  } | {
@@ -30,10 +30,10 @@ export declare function previewProjectInstructions(repo: string): {
30
30
  installed: boolean;
31
31
  };
32
32
  export declare function addProjectInstructions(repo: string, fingerprint: string): {
33
- content?: never;
34
33
  ok: false;
35
34
  message: string;
36
35
  plan?: never;
36
+ content?: never;
37
37
  fingerprint?: never;
38
38
  installed?: never;
39
39
  } | import("./skills.js").InstallResult;
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
@@ -657,6 +657,9 @@ export function isTelegramProgressNotification(row) {
657
657
  return false;
658
658
  if (row.kind === "build-failed" && row.run !== null && row.dedupeKey === `run:${row.run}:failed`)
659
659
  return true;
660
+ // The owner's lead took the attempt on, or let it lapse (lead-voice.ts): its card repaints in place.
661
+ if ((row.kind === "lead-on-it" || row.kind === "lead-lapsed") && row.run !== null)
662
+ return true;
660
663
  return isLifecycleNotification(row) &&
661
664
  ["run-started", "run-phase", "run-finished", "review-requested", "review-finished", "run-stopping", "run-stopped", "run-resumed", "task-held", "task-released"].includes(row.kind);
662
665
  }
@@ -21621,14 +21624,18 @@ export class Store {
21621
21624
  * under a claim that lapsed (a crash mid-turn), and not deferred past now.
21622
21625
  * One at a time per bot — the mate runs one turn per approver anyway.
21623
21626
  */
21624
- claimTelegramConversation(botId, owner, ttlMs, now) {
21627
+ claimTelegramConversation(botId, owner, ttlMs, now, only) {
21625
21628
  return this.transact(() => {
21626
21629
  const stamp = now.toISOString();
21630
+ // "turns": still needs the assistant; "replies": the reply is planned and only sending remains.
21631
+ const planned = "EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id)";
21632
+ const which = only === "turns" ? `AND NOT ${planned}` : only === "replies" ? `AND ${planned}` : "";
21627
21633
  const row = this.db
21628
21634
  .prepare(`SELECT * FROM telegram_conversation
21629
21635
  WHERE bot_id = ? AND state IN ('queued','running')
21630
21636
  AND (claim_expires_at IS NULL OR claim_expires_at <= ?)
21631
21637
  AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
21638
+ ${which}
21632
21639
  ORDER BY id LIMIT 1`)
21633
21640
  .get(botId, stamp, stamp);
21634
21641
  if (row === undefined)
@@ -21686,6 +21693,31 @@ export class Store {
21686
21693
  .run(result.state, result.outcome, result.replyMessageId ?? null, stamp, id, owner, stamp);
21687
21694
  return Number(changes) === 1;
21688
21695
  }
21696
+ /**
21697
+ * Replies the assistant wrote that still have not reached the person
21698
+ * `since` ago or longer (status and the console show them with a Retry):
21699
+ * the message's row, whose it is, when the oldest unsent part was
21700
+ * written, and the last send error.
21701
+ */
21702
+ unsentTelegramReplies(botId, approver, since) {
21703
+ return this.db
21704
+ .prepare(`SELECT c.id, c.approver, MIN(p.created_at) AS since,
21705
+ (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
21706
+ FROM telegram_conversation c JOIN telegram_conversation_part p ON p.conversation = c.id AND p.state = 'pending'
21707
+ WHERE c.state IN ('queued','running') AND (? IS NULL OR c.bot_id = ?) AND (? IS NULL OR c.approver = ?)
21708
+ GROUP BY c.id HAVING MIN(p.created_at) <= ? ORDER BY c.id`)
21709
+ .all(botId, botId, approver, approver, since.toISOString())
21710
+ .map(row => ({ conversation: Number(row["id"]), approver: String(row["approver"]), since: String(row["since"]), error: row["error"] === null ? null : String(row["error"]) }));
21711
+ }
21712
+ /** Send unsent replies on the next bridge pass instead of waiting out their backoff; returns how many. */
21713
+ retryTelegramReplies(approver, now) {
21714
+ const { changes } = this.db
21715
+ .prepare(`UPDATE telegram_conversation SET next_attempt_at = ?
21716
+ WHERE state = 'queued' AND (? IS NULL OR approver = ?)
21717
+ AND EXISTS (SELECT 1 FROM telegram_conversation_part p WHERE p.conversation = telegram_conversation.id AND p.state = 'pending')`)
21718
+ .run(now.toISOString(), approver, approver);
21719
+ return Number(changes);
21720
+ }
21689
21721
  // ---- the outbound parts (v63) ------------------------------------------------
21690
21722
  /**
21691
21723
  * Persist the whole outbound half of a turn — every reply part and every
@@ -22224,7 +22256,12 @@ export class Store {
22224
22256
  return true;
22225
22257
  if (row.recipient !== null && row.kind === "task-ask")
22226
22258
  return true;
22259
+ // The lead's own words reach the person it speaks for, and its claim repaints only that person's card (lead-voice.ts).
22260
+ if (row.kind === "lead-say")
22261
+ return row.recipient === account;
22227
22262
  const actor = this.notificationActor(row.id);
22263
+ if (row.kind === "lead-on-it" || row.kind === "lead-lapsed")
22264
+ return actor?.lead === true && actor.account === account;
22228
22265
  if (actor !== null && actor.account === account)
22229
22266
  return false;
22230
22267
  if (row.recipient !== null)
package/dist/surface.js CHANGED
@@ -194,7 +194,7 @@ export const COMMAND_GUIDE = [
194
194
  invocation: `assignment ${action}`,
195
195
  synopsis: action === "show" ? "read the root, current work, owner and exact receipt"
196
196
  : action === "updates" ? "read durable updates after a cursor; save nextCursor after processing"
197
- : action === "claim" ? "record coordinator lead ownership; grants no approval or execution authority"
197
+ : action === "claim" ? "record lead ownership; with the lead token the task reads \"Your lead is on it\" and leaves Needs you until done, handed over, or 2 hours idle; grants no approval or execution authority"
198
198
  : action === "brief" ? "catch up from current assignments and project knowledge in the local database"
199
199
  : action === "inbox" ? "receive or replay a saved batch of lead status updates"
200
200
  : action === "ack" ? "acknowledge delivery of an inbox batch; leaves task completion unchanged"
@@ -215,6 +215,13 @@ export const COMMAND_GUIDE = [
215
215
  })),
216
216
  operator("task state", "set a task's state by hand — an operator correction, not a workflow step (--replaced-by <id> with cancelled)"),
217
217
  operator("lead token", "mint the lead's credential behind your password (--revoke ends it): its own work pings nobody"),
218
+ { invocation: "lead say", synopsis: "as the lead: one short message in its person's chat from \"Your lead\"; several within two minutes are one message",
219
+ audience: "agent", agentMayInvoke: true, mutation: "unkeyed",
220
+ positionals: [{ name: "text", required: true, meaning: "one short line in plain words" }],
221
+ flags: [jsonFlag, dbFlag,
222
+ { name: "task", takesValue: true, meaning: "the task it is about" },
223
+ { name: "token", takesValue: true, meaning: "the lead token (or TOOLROLL_LEAD_TOKEN)" }],
224
+ notableReasons: ["usage", "unknown-task", "unauthenticated"] },
218
225
  { invocation: "task block", synopsis: "make one task wait for another", audience: "agent", agentMayInvoke: true, mutation: "keyed",
219
226
  positionals: [{ name: "id", required: true, meaning: "the waiting task" }],
220
227
  flags: [jsonFlag, dbFlag, keyFlag, { name: "on", takesValue: true, meaning: "the blocker's id" }],
@@ -107,8 +107,14 @@ export type TaskStatusFacts = {
107
107
  };
108
108
  /** Exact technical reasons, shown only on request. */
109
109
  why?: readonly string[];
110
+ /** The person's lead took this on (lead-voice.ts): "on-it" reads "Your lead is on it" and needs nobody; "lapsed"
111
+ * (two hours without a lead act) is back with the person, and says so. */
112
+ lead?: "on-it" | "lapsed" | null;
110
113
  };
111
114
  export declare function headlineOf(facts: Pick<TaskStatusFacts, "stage" | "checks" | "report">): Headline;
115
+ /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
116
+ export declare const LEAD_ON_IT = "Your lead is on it.";
117
+ export declare const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
112
118
  export declare function taskStatusOf(facts: TaskStatusFacts): TaskStatus;
113
119
  export declare function plainReasonOf(stage: TaskStage, code: string, detail: string | null | undefined): string | null;
114
120
  export declare function stageOfCode(code: string, options?: {
@@ -133,13 +133,29 @@ function detailsOf(headline, facts) {
133
133
  }
134
134
  return rows;
135
135
  }
136
+ /** What the lead's claim says, in place of "waits for you" (lead-voice.ts). */
137
+ export const LEAD_ON_IT = "Your lead is on it.";
138
+ export const LEAD_LAPSED = "Your lead hasn't acted on this for 2 hours, so it's back with you.";
139
+ /** The headlines a lead's claim speaks for: the ones that would otherwise wait on a person. */
140
+ const LEAD_HEADLINES = new Set(["Needs you", "Failed", "Ready for review", "Stopped"]);
141
+ function leadSentence(headline, facts) {
142
+ const sha = short(facts.checks?.head ?? null);
143
+ // A failed check stays said: the claim changes who acts next, never the result.
144
+ if (headline === "Failed" && facts.checks?.status === "failed")
145
+ return `${facts.checks.level === "quick" ? "Quick checks" : "Checks"} failed${sha === null ? "" : ` on ${sha}`}. ${LEAD_ON_IT}`;
146
+ return LEAD_ON_IT;
147
+ }
136
148
  export function taskStatusOf(facts) {
137
- const headline = headlineOf(facts);
149
+ const read = headlineOf(facts);
150
+ const lead = facts.lead != null && LEAD_HEADLINES.has(read) ? facts.lead : null;
151
+ // The lead has it: nothing waits on the person, so a Needs you reads Waiting.
152
+ const headline = lead === "on-it" && read === "Needs you" ? "Waiting" : read;
138
153
  // Every Needs you carries the one action that resolves it, worded the same everywhere.
139
154
  const need = headline === "Needs you" ? { key: facts.need ?? "other", action: NEEDS[facts.need ?? "other"].action } : null;
140
155
  const ownWords = need !== null && (need.key === "other" || need.key === "review-result") && facts.action != null;
141
156
  const primaryAction = need === null || ownWords ? facts.action ?? null : { label: need.action.label, href: facts.action?.href ?? null };
142
- return { headline, tone: HEADLINE_TONE[headline], sentence: sentenceOf(headline, facts), details: detailsOf(headline, facts),
157
+ const sentence = lead === "on-it" ? leadSentence(headline, facts) : lead === "lapsed" ? `${LEAD_LAPSED} ${sentenceOf(headline, facts)}` : sentenceOf(headline, facts);
158
+ return { headline, tone: HEADLINE_TONE[headline], sentence, details: detailsOf(headline, facts),
143
159
  primaryAction, why: [...new Set(facts.why ?? [])].filter(one => one.trim() !== ""), need };
144
160
  }
145
161
  // ---- reading the existing projections ----------------------------------------
@@ -287,6 +303,7 @@ export function assignmentStatusFacts(assignment, options = {}) {
287
303
  action: options.action ?? null,
288
304
  ...(options.links === undefined ? {} : { links: options.links }),
289
305
  why: options.why ?? [],
306
+ ...(assignment.lead == null ? {} : { lead: assignment.lead.state }),
290
307
  };
291
308
  }
292
309
  // ---- rendering helpers shared by the server pages and chat cards ------------
package/dist/team-cli.js CHANGED
@@ -306,7 +306,7 @@ function render(reply, listing) {
306
306
  export async function maybeRunTeamCommand(argv, write, options = {}) {
307
307
  const command = argv[0];
308
308
  // `lead token` is local: the credential a lead agent acts with on this computer.
309
- if (command === 'lead' && argv[1] === 'token')
309
+ if (command === 'lead' && (argv[1] === 'token' || argv[1] === 'say'))
310
310
  return null;
311
311
  if (!['connect', 'lead', 'conversation', 'chat', 'brief'].includes(command ?? '') && !argv.includes('--profile'))
312
312
  return null;
@@ -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
  }
@@ -8,6 +8,7 @@ import { assignmentOf } from "./assignment.js";
8
8
  import { chatResultHref, chatControlHref } from "./chat-controls.js";
9
9
  import { phoneText, projectLabel } from "./telegram-status.js";
10
10
  import { assignmentStatusFacts, headlineEmoji, pullRequestFactOf, requirementsOf, statusDetailLines, taskStatusOf } from "./task-status.js";
11
+ import { leadClaimOf } from "./lead-voice.js";
11
12
  import { chatTitle } from "./chat-voice.js";
12
13
  import { manualReviewOnly } from "./proof.js";
13
14
  import { failedCheckExit } from "./workspace-ui.js";
@@ -78,6 +79,12 @@ export function telegramProgressCard(store, run, taskId, project, now = new Date
78
79
  const completer = assignment?.completion?.actor.replace(/^(?:operator|coordinator|lead):/, "") ?? null;
79
80
  if (viewer !== undefined && facts.completedBy != null && (completer === viewer || facts.completedBy === viewer))
80
81
  facts = { ...facts, completedBy: null };
82
+ // The person's lead took it on: "Your lead is on it" rather than waiting for them (lead-voice.ts).
83
+ if (facts.lead === undefined) {
84
+ const lead = leadClaimOf(store, taskId, now, viewer);
85
+ if (lead !== null)
86
+ facts = { ...facts, lead: lead.state };
87
+ }
81
88
  const status = taskStatusOf(facts);
82
89
  const checkProgress = store.checkProgress(run.id);
83
90
  const progress = status.headline === "Building" && checkProgress !== null ? [`● ${checkProgress.line}`] : [];
@@ -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;