pi-braid-telegram 0.0.0-stage → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,716 @@
1
+ /**
2
+ * LIVE-UPDATE tracked message.
3
+ *
4
+ * The live-update trick: instead of re-sending a new Telegram message for every
5
+ * progress update (which spams the owner's chat), the tracker sends ONE pinned
6
+ * message immediately when the owner's message arrives ("⚙️ Working… received
7
+ * your message"), then EDITS that same message in place as work progresses, and
8
+ * finally edits it to a "✅ Done" state and sends a SEPARATE beautiful
9
+ * markdown summary via the (key-ring-backed) crafting model.
10
+ *
11
+ * Telegram's edit affordance keeps the edited message pinned at its original
12
+ * position — no new-message spam, no reordering. `message_id` + `chat_id` are
13
+ * persisted to a small JSON state file (`.braid/telegram-live-update.json`,
14
+ * mirroring telegram-state.json) so a process reload can resume editing the
15
+ * same message instead of starting over.
16
+ *
17
+ * Design decisions (see docs/live-update.md for the full list):
18
+ * - default ON via BRAID_TG_LIVE_UPDATE_ENABLED (explicit "0" disables);
19
+ * - sliding-window rate limit (default 4/min) applies to start/update edits —
20
+ * finish() is terminal and always attempts its done-edit + final summary;
21
+ * - start() is idempotent: an existing tracked message (incl. A persisted one
22
+ * from a prior run) is reused, never a duplicate;
23
+ * - the tracker is pure-of-network: client calls and clock are injected so
24
+ * fixture tests run with zero real API calls.
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
29
+ import { dirname } from "node:path";
30
+ import { SlidingWindowLimiter } from "./rate-limit.js";
31
+ import { defaultLiveUpdateFilePath } from "../state-paths.js";
32
+ import {
33
+ clearAgentDeliveredMessage,
34
+ wasAgentDeliveredMessage,
35
+ } from "./agent-delivery.js";
36
+ import {
37
+ sendMessageChunks,
38
+ resolveDeliveryFormat,
39
+ TELEGRAM_MAX_TEXT_LENGTH,
40
+ type SendChunksResult,
41
+ } from "../format.js";
42
+ import type { TelegramClient } from "../telegram-client.js";
43
+
44
+ // Types
45
+
46
+ /** Persisted shape: the tracked message that survives restarts. */
47
+ export interface LiveUpdateState {
48
+ messageId: number;
49
+ chatId: number | string;
50
+ startedAt: number;
51
+ }
52
+
53
+ export interface LiveUpdateCraftRich {
54
+ (text: string): Promise<{ text: string; crafted: boolean } | null>;
55
+ }
56
+
57
+ export interface LiveUpdateDeps {
58
+ client: Pick<TelegramClient, "sendMessage" | "editMessageText">;
59
+ token: string;
60
+ /**
61
+ * Target chat id, or a resolver (index.ts passes `resolveChatId` so the
62
+ * chat is captured from the first inbound owner message, not at load time).
63
+ */
64
+ chatId: number | string | (() => number | string);
65
+ /** Path to the live-update JSON state file (test-fixture-friendly). */
66
+ stateFile: string;
67
+ /** Clock (injectable for deterministic rate-limit tests). */
68
+ now?(): number;
69
+ /** Max progress edits per window (default 4/min, BRAID_TG_LIVE_UPDATE_MAX_PER_MIN). */
70
+ maxPerWindow?: number;
71
+ windowMs?: number;
72
+ /** Edit/send body cap (Telegram's 4096). */
73
+ maxTextLength?: number;
74
+ log?: (
75
+ level: "debug" | "info" | "warn" | "error",
76
+ message: string,
77
+ ...rest: unknown[]
78
+ ) => void;
79
+ }
80
+
81
+ export interface LiveUpdateStartOutcome {
82
+ started: boolean;
83
+ /** true when an existing tracked message was reused (no new send). */
84
+ reused: boolean;
85
+ messageId?: number;
86
+ reason?: "rate-limited" | "send-failed" | "no-chat";
87
+ error?: string;
88
+ }
89
+
90
+ export interface LiveUpdateUpdateOutcome {
91
+ updated: boolean;
92
+ reason?: "rate-limited" | "no-tracked-message";
93
+ error?: string;
94
+ }
95
+
96
+ export interface LiveUpdateFinishOutcome {
97
+ completed: boolean;
98
+ doneEdited: boolean;
99
+ /** The separate final summary was delivered. */
100
+ summarySent: boolean;
101
+ /** Number of summary messages sent (split-to-fit; 0 when none). */
102
+ summaryChunks?: number;
103
+ error?: string;
104
+ }
105
+
106
+ export interface LiveUpdateTerminateOutcome {
107
+ terminated: boolean;
108
+ /** The tracked message was edited to the terminal "no report" state. */
109
+ terminalEdited: boolean;
110
+ error?: string;
111
+ }
112
+
113
+ // Defaults
114
+
115
+ export const DEFAULT_LIVE_UPDATE_MAX_PER_MIN = 4;
116
+ export const DEFAULT_LIVE_UPDATE_WINDOW_MS = 60_000;
117
+
118
+ /** Status line shown in the tracked message immediately on inbound. */
119
+ export const TRACKED_START_TEXT = "⚙️ Working… received your message";
120
+
121
+ /** Status line the tracked message is edited to when the turn completes. */
122
+ export const TRACKED_DONE_TEXT = "✅ Done — full report below.";
123
+
124
+ /** Done-edit variant when the agent ALREADY delivered its own report via
125
+ * telegram_send during the turn — finish() suppresses its separate summary
126
+ * (no duplicate message), so the honest pointer is "above", not "below". */
127
+ export const TRACKED_DONE_TEXT_REPORT_SENT = "✅ Done — report above.";
128
+
129
+ /** Status line the tracked message is edited to when a turn ends WITHOUT a
130
+ * report (abort/error). Honest, distinct from TRACKED_DONE_TEXT. */
131
+ export const TRACKED_TERMINAL_TEXT = "⏹️ Turn ended without a report";
132
+
133
+ /** Live-update state file path (mirrors inbound-poller state-file conventions).
134
+ * Null when no absolute braid root resolves; the caller refuses the tracker. */
135
+ export function defaultLiveUpdateStateFile(): string | null {
136
+ return defaultLiveUpdateFilePath();
137
+ }
138
+
139
+ // State persistence (mirrors inbound-poller loadState/saveState)
140
+
141
+ export async function loadLiveState(
142
+ stateFile: string,
143
+ ): Promise<LiveUpdateState | undefined> {
144
+ try {
145
+ const raw = await readFile(stateFile, "utf8");
146
+ const parsed = JSON.parse(raw) as Partial<LiveUpdateState>;
147
+ const messageId = Number(parsed?.messageId);
148
+ if (!Number.isInteger(messageId) || messageId <= 0) return undefined;
149
+ const chatId = parsed?.chatId;
150
+ if (chatId == null) return undefined;
151
+ const startedAt = Number(parsed?.startedAt) || 0;
152
+ return { messageId, chatId, startedAt };
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ export async function saveLiveState(
159
+ stateFile: string,
160
+ state: LiveUpdateState,
161
+ ): Promise<void> {
162
+ const dir = dirname(stateFile);
163
+ try {
164
+ await mkdir(dir, { recursive: true });
165
+ } catch {
166
+ // parent may already exist; write will surface real errors
167
+ }
168
+ await writeLiveStateAtomic(stateFile, state);
169
+ }
170
+
171
+ /**
172
+ * Persist one state document ATOMICALLY (write-temp-rename, same rationale as
173
+ * saveInboundQueue and savePauseState). A plain `writeFile` opens with O_TRUNC
174
+ * and writes afterwards, so a reader parsing the file inside that window — or
175
+ * a second saver completing its own truncate-then-write in between — observes
176
+ * a state that was never written: an empty file, or one document followed by
177
+ * the tail of a longer one. The cost is a parse error in the RESUMER (a
178
+ * persisted tracked message is then abandoned as "no tracked message") and in
179
+ * every concurrent reader of the file. The temp name is unique per write: a
180
+ * shared temp made concurrent savers fail each other's rename (ENOENT), while
181
+ * unique temps all land and the last rename wins with a whole snapshot.
182
+ */
183
+ async function writeLiveStateAtomic(
184
+ stateFile: string,
185
+ state: LiveUpdateState,
186
+ ): Promise<void> {
187
+ const tmp = `${stateFile}.${randomUUID()}.tmp`;
188
+ await writeFile(tmp, JSON.stringify(state, null, 2) + "\n", "utf8");
189
+ await rename(tmp, stateFile);
190
+ }
191
+
192
+ /** Remove the state file (called after finish — the tracked message is done). */
193
+ export async function clearLiveState(stateFile: string): Promise<void> {
194
+ try {
195
+ await writeLiveStateAtomic(stateFile, {
196
+ messageId: 0,
197
+ chatId: 0,
198
+ startedAt: 0,
199
+ });
200
+ } catch {
201
+ // best-effort; stale state is harmless (loadLiveState ignores messageId 0)
202
+ }
203
+ }
204
+
205
+ function clipText(text: string, maxLen: number): string {
206
+ const trimmed = text.trim();
207
+ if (trimmed.length <= maxLen) return trimmed;
208
+ return trimmed.slice(0, maxLen - 1).trimEnd() + "…";
209
+ }
210
+
211
+ // Tracker
212
+
213
+ export interface LiveUpdateTracker {
214
+ /** Send the immediate "Working…" tracked message (or reuse an existing one). */
215
+ start(): Promise<LiveUpdateStartOutcome>;
216
+ /** Edit the tracked message in place (idempotent + rate-limited). */
217
+ update(text: string): Promise<LiveUpdateUpdateOutcome>;
218
+ /**
219
+ * Edit the tracked message to "✅ Done", then deliver a SEPARATE final
220
+ * markdown summary (crafted via `craftRich` when supplied/fit, else the raw
221
+ * `resultText` split-to-fit). Terminal — always attempts its edits.
222
+ */
223
+ finish(opts: {
224
+ resultText: string;
225
+ craftRich?: LiveUpdateCraftRich;
226
+ /** Skip the separate final summary (the agent already delivered its own
227
+ * report via telegram_send this turn — no duplicate message). The
228
+ * tracked message is still edited to its done state. */
229
+ suppressSummary?: boolean;
230
+ }): Promise<LiveUpdateFinishOutcome>;
231
+ /**
232
+ * Terminate the tracked message on a terminal non-completed turn (abort /
233
+ * error — there is no report to summarize): edit it to "⏹️ Turn ended
234
+ * without a report" (best-effort) and ALWAYS clear the tracked state + state
235
+ * file so a reload never resurrects a stale "Working…" message. Terminal —
236
+ * non-throwing, mirrors finish()'s always-attempts semantics.
237
+ */
238
+ terminate(): Promise<LiveUpdateTerminateOutcome>;
239
+ /** True while a tracked message is active (in-memory or persisted). */
240
+ isActive(): Promise<boolean>;
241
+ /** Current tracked state (undefined when none). */
242
+ getState(): Promise<LiveUpdateState | undefined>;
243
+ /**
244
+ * ACTIVE-WORK REFCOUNT: the "⚙️ Working…" indicator is gated
245
+ * on real in-flight work, not on a fragile turn_end mapping. Begin()
246
+ * increments — 0→1 pins (or reuses) the tracked message; end() decrements —
247
+ * 1→0 resolves it to the honest terminal state. The indicator completion
248
+ * NEVER depends on the agent calling a finalize tool alone: settle() at an
249
+ * idle/settled boundary force-resolves any still-pinned indicator.
250
+ */
251
+ /** +1 active work unit; on 0→1 pins (or reuses) the tracked Working… message. */
252
+ begin(): Promise<LiveUpdateStartOutcome>;
253
+ /**
254
+ * -1 active work unit; on 1→0 resolves the indicator to the honest terminal
255
+ * state — "done" → ✅ Done + final summary; "no-report" → ⏹.
256
+ */
257
+ end(
258
+ kind: LiveUpdateEndKind,
259
+ opts?: { resultText?: string; craftRich?: LiveUpdateCraftRich },
260
+ ): Promise<LiveUpdateEndOutcome>;
261
+ /** Current active-work counter (synchronous). */
262
+ activeCount(): number;
263
+ /**
264
+ * Never-freeze safety-net. Call at an agent_settled / idle boundary: if the
265
+ * indicator is still pinned while active work is stale/absent (and not
266
+ * marked mayContinue), force the honest terminal state so it ALWAYS resolves
267
+ * — even when the agent ended its turn without calling a finalize path.
268
+ */
269
+ settle(opts?: { mayContinue?: boolean }): Promise<LiveUpdateSettleOutcome>;
270
+ }
271
+
272
+ /** The two honest terminal outcomes a work unit can resolve the indicator to. */
273
+ export type LiveUpdateEndKind = "done" | "no-report";
274
+
275
+ export interface LiveUpdateEndOutcome {
276
+ /** true when this end() resolved the indicator (active counter reached 0). */
277
+ ended: boolean;
278
+ /** Active-work counter after this call. */
279
+ active: number;
280
+ /** Set when ended && the unit was "done": tracked message edited to ✅ Done. */
281
+ doneEdited?: boolean;
282
+ /** Set when ended && the unit was "done": the separate final summary went out. */
283
+ summarySent?: boolean;
284
+ /** Set when ended && the unit was "no-report": the terminal ⏹ edit was made. */
285
+ terminalEdited?: boolean;
286
+ error?: string;
287
+ }
288
+
289
+ export interface LiveUpdateSettleOutcome {
290
+ /** true when the safety-net forced an honest completion (never a freeze). */
291
+ resolved: boolean;
292
+ /** true when the indicator was already resolved (nothing left to force). */
293
+ alreadyResolved: boolean;
294
+ /** Active-work counter (0 after a forced resolve). */
295
+ active: number;
296
+ reason?:
297
+ | "already-resolved"
298
+ | "no-active-work"
299
+ | "might-continue"
300
+ | "resolved";
301
+ doneEdited?: boolean;
302
+ terminalEdited?: boolean;
303
+ error?: string;
304
+ }
305
+
306
+ export function createLiveUpdateTracker(
307
+ deps: LiveUpdateDeps,
308
+ ): LiveUpdateTracker {
309
+ const limiter = new SlidingWindowLimiter({
310
+ maxPerWindow: deps.maxPerWindow ?? DEFAULT_LIVE_UPDATE_MAX_PER_MIN,
311
+ windowMs: deps.windowMs ?? DEFAULT_LIVE_UPDATE_WINDOW_MS,
312
+ });
313
+ const maxTextLen = deps.maxTextLength ?? TELEGRAM_MAX_TEXT_LENGTH;
314
+ const now = deps.now ?? (() => Date.now());
315
+ const log = deps.log ?? (() => {});
316
+ const chatIdOf = (): number | string =>
317
+ typeof deps.chatId === "function"
318
+ ? (deps.chatId as () => number | string)()
319
+ : (deps.chatId as number | string);
320
+
321
+ // Loaded lazily once per tracker from the state file; mutated in memory and
322
+ // persisted on every change (start sets it, finish clears it).
323
+ let tracked: LiveUpdateState | undefined;
324
+ let loaded = false;
325
+
326
+ // Active-work refcount: begin()/end() gate the indicator on
327
+ // real in-flight work; settle() is the never-freeze safety-net. `cycle` is
328
+ // per-pinning-cycle: "none" (no owner work begun), "active" (pinned, units
329
+ // in flight), "resolved" (this cycle already completed honestly).
330
+ // `pendingKind` is the outcome of the most recent end()'d unit — it decides
331
+ // the honest terminal state when the counter reaches 0 (or settle forces);
332
+ // `lastResultText`/`lastCraftRich` carry the latest completed answer into
333
+ // settle()'s forced-completion fallback.
334
+ let active = 0;
335
+ let cycle: "none" | "active" | "resolved" = "none";
336
+ let pendingKind: LiveUpdateEndKind = "done";
337
+ let lastResultText = "";
338
+ let lastCraftRich: LiveUpdateCraftRich | undefined;
339
+
340
+ async function ensureLoaded(): Promise<LiveUpdateState | undefined> {
341
+ if (!loaded) {
342
+ loaded = true;
343
+ tracked = await loadLiveState(deps.stateFile);
344
+ if (tracked)
345
+ log(
346
+ "info",
347
+ `live-update: resumed tracked message ${tracked.messageId} in chat ${tracked.chatId}`,
348
+ );
349
+ }
350
+ return tracked;
351
+ }
352
+
353
+ function setTracked(messageId: number, startedAt: number): void {
354
+ tracked = { messageId, chatId: chatIdOf(), startedAt };
355
+ }
356
+
357
+ async function clearTracked(): Promise<void> {
358
+ tracked = undefined;
359
+ await clearLiveState(deps.stateFile);
360
+ }
361
+
362
+ /** Deliver the final summary — crafted markdown when fit, else raw text. */
363
+ async function deliverSummary(
364
+ resultText: string,
365
+ craftRich?: LiveUpdateCraftRich,
366
+ ): Promise<SendChunksResult> {
367
+ const trimmed = resultText.trim();
368
+ if (!trimmed) return { ok: true, sent: 0, total: 0, messageIds: [] };
369
+ const summaryChatId = stateChatId() ?? chatIdOf();
370
+ if (craftRich) {
371
+ try {
372
+ const crafted = await craftRich(trimmed);
373
+ if (crafted?.crafted && crafted.text?.trim()) {
374
+ const body = crafted.text.trim();
375
+ // Resolve the delivery format from the crafted BODY itself:
376
+ // raw HTML tags → HTML mode (Markdown markers neutralized so nothing
377
+ // leaks); balanced Markdown → Markdown; anything else (or unbalanced
378
+ // delimiters) → plain text rather than a Telegram API error. The
379
+ // craft prompt already asks the model to emit the right markup; this
380
+ // guard catches the model drifting to the other format anyway. The
381
+ // detectMarkdown flag opts the no-request body into active Markdown
382
+ // detection.
383
+ const fmt = resolveDeliveryFormat(undefined, body, true);
384
+ if (!fmt.parseMode) {
385
+ log(
386
+ "warn",
387
+ "live-update: crafted summary has no clean format (unbalanced markdown) — delivering plain text",
388
+ );
389
+ }
390
+ return sendMessageChunks(deps.client, {
391
+ token: deps.token,
392
+ chatId: summaryChatId,
393
+ text: fmt.text,
394
+ parseMode: fmt.parseMode,
395
+ });
396
+ }
397
+ log(
398
+ "warn",
399
+ "live-update: rich summary crafting produced no usable text — sending raw result",
400
+ );
401
+ } catch (err) {
402
+ log(
403
+ "warn",
404
+ `live-update: rich summary crafting threw (${err instanceof Error ? err.message : String(err)}) — sending raw result`,
405
+ );
406
+ }
407
+ }
408
+ return sendMessageChunks(deps.client, {
409
+ token: deps.token,
410
+ chatId: summaryChatId,
411
+ text: trimmed,
412
+ });
413
+ }
414
+
415
+ /** chat id of the tracked message (persisted state), else undefined. */
416
+ function stateChatId(): number | string | undefined {
417
+ return tracked?.chatId;
418
+ }
419
+
420
+ /** Resolve the indicator to the honest terminal state for `kind`, zeroing
421
+ * the active-work counter. Shared by end() (1→0) and settle() (forced). */
422
+ async function resolveIndicator(kind: LiveUpdateEndKind): Promise<{
423
+ doneEdited: boolean;
424
+ summarySent: boolean;
425
+ terminalEdited: boolean;
426
+ error?: string;
427
+ }> {
428
+ if (kind === "no-report") {
429
+ const out = await tracker.terminate();
430
+ return {
431
+ doneEdited: false,
432
+ summarySent: false,
433
+ terminalEdited: out.terminalEdited,
434
+ error: out.error,
435
+ };
436
+ }
437
+ // Suppress the auto summary when the agent already delivered an explicit
438
+ // report this turn (telegram_send marked the flag). Read-then-clear here —
439
+ // this is the single boundary both end() and settle() resolve through.
440
+ const suppressSummary = wasAgentDeliveredMessage();
441
+ clearAgentDeliveredMessage();
442
+ const out = await tracker.finish({
443
+ resultText: lastResultText,
444
+ craftRich: lastCraftRich,
445
+ suppressSummary,
446
+ });
447
+ return {
448
+ doneEdited: out.doneEdited,
449
+ summarySent: out.summarySent,
450
+ terminalEdited: false,
451
+ error: out.error,
452
+ };
453
+ }
454
+
455
+ const tracker: LiveUpdateTracker = {
456
+ async start(): Promise<LiveUpdateStartOutcome> {
457
+ // Fresh work unit → the agent-delivered flag from a prior turn must not
458
+ // leak into it (defensive: the resolver already clears on read).
459
+ clearAgentDeliveredMessage();
460
+ const existing = await ensureLoaded();
461
+ if (existing?.messageId) {
462
+ // Reuse the persisted/active tracked message (no new-message spam).
463
+ await deps.client.editMessageText(
464
+ deps.token,
465
+ existing.chatId,
466
+ existing.messageId,
467
+ clipText(TRACKED_START_TEXT, maxTextLen),
468
+ );
469
+ return { started: true, reused: true, messageId: existing.messageId };
470
+ }
471
+ const chatId = chatIdOf();
472
+ if (!chatId) return { started: false, reused: false, reason: "no-chat" };
473
+ if (!limiter.try(now()))
474
+ return { started: false, reused: false, reason: "rate-limited" };
475
+ const res = await deps.client.sendMessage(
476
+ deps.token,
477
+ chatId,
478
+ TRACKED_START_TEXT,
479
+ );
480
+ if (!res.ok)
481
+ return {
482
+ started: false,
483
+ reused: false,
484
+ reason: "send-failed",
485
+ error: res.error,
486
+ };
487
+ const messageId = res.messageId ?? 0;
488
+ if (messageId > 0) {
489
+ // Only a real message_id is editable — track + persist it. Without one
490
+ // (shouldn't happen for sendMessage, but defensively) the Working…
491
+ // message still went out; finish() falls back to summary-only.
492
+ setTracked(messageId, now());
493
+ try {
494
+ await saveLiveState(deps.stateFile, tracked!);
495
+ } catch (error) {
496
+ log(
497
+ "warn",
498
+ `live-update: failed to persist tracked message: ${String(error)}`,
499
+ );
500
+ }
501
+ log(
502
+ "info",
503
+ `live-update: tracked message ${messageId} started in chat ${chatId}`,
504
+ );
505
+ }
506
+ return {
507
+ started: true,
508
+ reused: false,
509
+ ...(messageId > 0 ? { messageId } : {}),
510
+ };
511
+ },
512
+
513
+ async update(text): Promise<LiveUpdateUpdateOutcome> {
514
+ const state = await ensureLoaded();
515
+ if (!state?.messageId)
516
+ return { updated: false, reason: "no-tracked-message" };
517
+ if (!limiter.try(now()))
518
+ return { updated: false, reason: "rate-limited" };
519
+ const res = await deps.client.editMessageText(
520
+ deps.token,
521
+ state.chatId,
522
+ state.messageId,
523
+ clipText(text, maxTextLen),
524
+ );
525
+ return res.ok ? { updated: true } : { updated: false, error: res.error };
526
+ },
527
+
528
+ async finish({
529
+ resultText,
530
+ craftRich,
531
+ suppressSummary = false,
532
+ }): Promise<LiveUpdateFinishOutcome> {
533
+ const state = await ensureLoaded();
534
+ let doneEdited = false;
535
+ if (state?.messageId) {
536
+ // Terminal edit first: pin the visited message to its done state.
537
+ const done = await deps.client.editMessageText(
538
+ deps.token,
539
+ state.chatId,
540
+ state.messageId,
541
+ suppressSummary ? TRACKED_DONE_TEXT_REPORT_SENT : TRACKED_DONE_TEXT,
542
+ );
543
+ doneEdited = done.ok;
544
+ if (!done.ok)
545
+ log(
546
+ "warn",
547
+ `live-update: done-edit failed (${done.error ?? "unknown"})`,
548
+ );
549
+ }
550
+
551
+ // Then the SEPARATE final summary — SKIPPED when the agent already
552
+ // delivered its own report this turn (the tracked done-edit still pins
553
+ // the conversation; the report above IS the deliverable).
554
+ const summary = suppressSummary
555
+ ? null
556
+ : await deliverSummary(resultText, craftRich);
557
+
558
+ // The tracked message served its purpose — clear it (state file too).
559
+ await clearTracked();
560
+
561
+ // Honest outcome: a suppressed summary is neither sent nor failed — the
562
+ // suppression itself is the intended terminal state.
563
+ const completed = doneEdited || (summary ? summarySentOk(summary) : true);
564
+ return {
565
+ completed,
566
+ doneEdited,
567
+ summarySent: summary?.ok ?? false,
568
+ summaryChunks: summary?.total ?? 0,
569
+ ...(completed
570
+ ? {}
571
+ : { error: "done-edit and summary send both failed" }),
572
+ };
573
+ },
574
+
575
+ async terminate(): Promise<LiveUpdateTerminateOutcome> {
576
+ const state = await ensureLoaded();
577
+ let terminalEdited = false;
578
+ if (state?.messageId) {
579
+ // Terminal edit first: pin the tracked message to "⏹️ Turn ended
580
+ // without a report" (never "✅ Done" — the turn did not complete).
581
+ const done = await deps.client.editMessageText(
582
+ deps.token,
583
+ state.chatId,
584
+ state.messageId,
585
+ TRACKED_TERMINAL_TEXT,
586
+ );
587
+ terminalEdited = done.ok;
588
+ if (!done.ok)
589
+ log(
590
+ "warn",
591
+ `live-update: terminal-edit failed (${done.error ?? "unknown"})`,
592
+ );
593
+ }
594
+ // ALWAYS clear the persisted state — a reload must never resurrect a
595
+ // stale "Working…" message, even when the edit failed or nothing was
596
+ // tracked. Non-throwing (state mutations cannot reject the caller).
597
+ await clearTracked();
598
+ return { terminated: true, terminalEdited };
599
+ },
600
+
601
+ async isActive(): Promise<boolean> {
602
+ const state = await ensureLoaded();
603
+ return Boolean(state?.messageId);
604
+ },
605
+
606
+ async getState(): Promise<LiveUpdateState | undefined> {
607
+ const state = await ensureLoaded();
608
+ return state ? { ...state } : undefined;
609
+ },
610
+
611
+ async begin(): Promise<LiveUpdateStartOutcome> {
612
+ const wasIdle = active === 0;
613
+ active += 1;
614
+ if (wasIdle) {
615
+ // 0→1: (re)pin the Working… indicator and start a fresh cycle.
616
+ cycle = "active";
617
+ pendingKind = "done";
618
+ lastResultText = "";
619
+ lastCraftRich = undefined;
620
+ return tracker.start();
621
+ }
622
+ // Already active — the indicator is pinned for every in-flight unit;
623
+ // just track the extra unit (no duplicate send). Reuse the existing
624
+ // tracked message; if the first pin attempt produced no editable message
625
+ // (send failed), retry the send so a Working… message does go out.
626
+ const existing = await ensureLoaded();
627
+ if (existing?.messageId) {
628
+ return { started: true, reused: true, messageId: existing.messageId };
629
+ }
630
+ return tracker.start();
631
+ },
632
+
633
+ async end(
634
+ kind: LiveUpdateEndKind,
635
+ opts?: { resultText?: string; craftRich?: LiveUpdateCraftRich },
636
+ ): Promise<LiveUpdateEndOutcome> {
637
+ if (kind === "done") {
638
+ if (opts?.resultText) lastResultText = opts.resultText;
639
+ if (opts?.craftRich) lastCraftRich = opts.craftRich;
640
+ }
641
+ pendingKind = kind;
642
+ active = Math.max(0, active - 1);
643
+ if (active > 0 || cycle !== "active") return { ended: false, active };
644
+ // 1→0 on an active cycle: resolve to the honest terminal state.
645
+ cycle = "resolved";
646
+ const r = await resolveIndicator(pendingKind);
647
+ return {
648
+ ended: true,
649
+ active,
650
+ doneEdited: r.doneEdited,
651
+ summarySent: r.summarySent,
652
+ terminalEdited: r.terminalEdited,
653
+ error: r.error,
654
+ };
655
+ },
656
+
657
+ activeCount(): number {
658
+ return active;
659
+ },
660
+
661
+ async settle(opts?: {
662
+ mayContinue?: boolean;
663
+ }): Promise<LiveUpdateSettleOutcome> {
664
+ if (cycle === "resolved") {
665
+ return {
666
+ resolved: false,
667
+ alreadyResolved: true,
668
+ active,
669
+ reason: "already-resolved",
670
+ };
671
+ }
672
+ if (opts?.mayContinue) {
673
+ return {
674
+ resolved: false,
675
+ alreadyResolved: false,
676
+ active,
677
+ reason: "might-continue",
678
+ };
679
+ }
680
+ const state = await ensureLoaded();
681
+ const hasTracked = Boolean(state?.messageId);
682
+ if (cycle !== "active" && !hasTracked && active === 0) {
683
+ return {
684
+ resolved: false,
685
+ alreadyResolved: false,
686
+ active,
687
+ reason: "no-active-work",
688
+ };
689
+ }
690
+ // Safety-net: the run is idle — force the honest completion so the
691
+ // indicator NEVER freezes at "Working…". An in-flight
692
+ // THIS-SESSION cycle settles with its last unit's outcome (✅ Done by
693
+ // default — the run finished); a resurrected stale pinned message (no
694
+ // active work this session) is a turn that ended without a report → ⏹.
695
+ const wasActive = cycle === "active";
696
+ cycle = "resolved";
697
+ active = 0;
698
+ const r = await resolveIndicator(wasActive ? pendingKind : "no-report");
699
+ return {
700
+ resolved: true,
701
+ alreadyResolved: false,
702
+ active,
703
+ reason: "resolved",
704
+ doneEdited: r.doneEdited,
705
+ terminalEdited: r.terminalEdited,
706
+ error: r.error,
707
+ };
708
+ },
709
+ };
710
+
711
+ return tracker;
712
+ }
713
+
714
+ function summarySentOk(summary: SendChunksResult): boolean {
715
+ return summary.ok && summary.sent > 0;
716
+ }