kiro-telegram-bot 1.6.0 → 1.7.1

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,76 @@
1
+ /**
2
+ * Parse the (noisy) stdout of `kiro-cli login --use-device-flow` into the few
3
+ * stable fields worth showing: the verification URL, the device code, and
4
+ * whether login completed.
5
+ *
6
+ * The CLI animates a spinner ("▰▱▱… Logging in…") by rewriting one terminal
7
+ * line with carriage returns. With stdio piped the `\r`s are stripped, so those
8
+ * frames pile up into one long string ("▰▱▱… Logging in…▰▰▱… Logging in…"). We
9
+ * discard that noise and surface only meaningful values, which the bot renders
10
+ * on a single, self-animated status line instead of echoing every frame.
11
+ */
12
+
13
+ /** Block/braille glyphs the CLI uses to draw progress bars / spinners. */
14
+ const BAR_CHARS = "▰▱▮▯■□▪▫●○◐◓◑◒⣾⣽⣻⢿⡿⣟⣯⣷⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
15
+ const BAR_CLASS = `[${BAR_CHARS}]`;
16
+
17
+ export interface DeviceFlow {
18
+ /** Verification URL, if the CLI printed one. */
19
+ url?: string;
20
+ /** Device/user code (e.g. `VKKH-PPMX`), if present. */
21
+ code?: string;
22
+ /** True once the CLI reports a completed login. */
23
+ loggedIn: boolean;
24
+ /** A short error line, if the output looks like a failure. */
25
+ error?: string;
26
+ }
27
+
28
+ /** Remove spinner frames, repeated "Logging in…" and stray bar glyphs. */
29
+ export function stripSpinner(raw: string): string {
30
+ return raw
31
+ .replace(/\r/g, "")
32
+ .replace(new RegExp(`${BAR_CLASS}+\\s*Logging in[.\\u2026]*`, "gi"), "")
33
+ .replace(/Logging in[.\u2026]*/gi, "")
34
+ .replace(new RegExp(`${BAR_CLASS}+`, "g"), "")
35
+ .replace(/[ \t]+\n/g, "\n")
36
+ .replace(/\n{3,}/g, "\n\n")
37
+ .trim();
38
+ }
39
+
40
+ const ERROR_RE = /error|failed|denied|expired|invalid|unable|timed out/i;
41
+
42
+ export function parseDeviceFlow(raw: string): DeviceFlow {
43
+ const text = stripSpinner(raw);
44
+
45
+ // Collect every URL, then prefer the device-verification one (it embeds the
46
+ // user code, so it's directly clickable). `firstUrl` guards against two URLs
47
+ // run together with no separator (terminal echo), which would otherwise be
48
+ // matched as one giant token.
49
+ const urls = (text.match(/https?:\/\/[^\s'"<>)\]]+/gi) ?? []).map(firstUrl);
50
+ const url = urls.find((u) => /user_code=|\/device/i.test(u)) ?? urls[0];
51
+
52
+ // Prefer the unambiguous XXXX-XXXX shape; fall back to a "Code:" label.
53
+ const code =
54
+ text.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4}\b/)?.[0] ??
55
+ text.match(/Code[:\s]+([A-Z0-9][A-Z0-9-]{3,})/)?.[1];
56
+
57
+ const loggedIn = /logged in|login successful|successfully logged in/i.test(text);
58
+
59
+ const error =
60
+ !loggedIn && ERROR_RE.test(text)
61
+ ? text
62
+ .split("\n")
63
+ .map((l) => l.trim())
64
+ .reverse()
65
+ .find((l) => ERROR_RE.test(l))
66
+ : undefined;
67
+
68
+ return { url, code, loggedIn, error };
69
+ }
70
+
71
+ /** Trim a token that accidentally contains two concatenated URLs down to the
72
+ * first one (e.g. "https://x/starthttps://x/start" → "https://x/start"). */
73
+ function firstUrl(u: string): string {
74
+ const second = u.indexOf("http", 4);
75
+ return second > 0 ? u.slice(0, second) : u;
76
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Task-progress support: parse the `{progress: N%}` marker the agent appends to
3
+ * its messages, strip it from the visible text, and render a green loading bar.
4
+ *
5
+ * The agent is asked (see PROGRESS_DIRECTIVE) to end each message with a marker
6
+ * like `{progress: 65%}`. The bot extracts the latest value, removes the marker
7
+ * so it never shows raw, and renders a 0–100% bar (filled = 🟩, empty = ⬜) on
8
+ * the live message, in session cards, and in the pinned status panel.
9
+ */
10
+
11
+ /** Matches a complete marker: `{progress: 65%}`, `{ progress:65 }`, etc. */
12
+ const PROGRESS_RE = /\{\s*progress\s*:\s*(\d{1,3})\s*%?\s*\}/gi;
13
+ /** Matches a trailing, not-yet-closed marker mid-stream (e.g. `…{progress: 6`). */
14
+ const PARTIAL_TAIL_RE = /\{\s*progress\b[^}]*$/i;
15
+
16
+ const FILLED = "\u{1F7E9}"; // 🟩
17
+ const EMPTY = "\u2B1C"; // ⬜
18
+ const SEGMENTS = 10;
19
+
20
+ /**
21
+ * The instruction appended to prompts so the agent emits a progress marker.
22
+ *
23
+ * IMPORTANT for maintainers:
24
+ * - The ONLY brace token may be the literal `{progress: N%}` with the letter
25
+ * `N` (never a digit). A digit inside braces would be parsed by PROGRESS_RE
26
+ * as a real value AND would break the exact-string strip in
27
+ * `sessions/history.ts` (`cleanStoredText`).
28
+ * - Keep this string "tidy-idempotent": no trailing spaces on any line, no run
29
+ * of 3+ newlines, and no trailing whitespace at the end. `cleanStoredText`
30
+ * runs `extractProgress` (which calls `tidy()`) before stripping the
31
+ * directive by exact match, so any whitespace `tidy()` would rewrite must
32
+ * not appear here, or the directive leaks into history/previews.
33
+ */
34
+ export const PROGRESS_DIRECTIVE = [
35
+ "PROGRESS REPORTING IS MANDATORY ON EVERY SINGLE MESSAGE YOU SEND \u2014 NO EXCEPTIONS.",
36
+ "Rule 1 (format): finish EVERY message with a task-completion marker on its own final line, in EXACTLY this format, with nothing at all after it: {progress: N%}",
37
+ "N is a plain integer from 0 to 100 (no decimals, no ranges, no math). The marker is the very last thing in the message: no text, punctuation, spaces, emoji, backticks, or code fences may follow it, and it must NEVER be placed inside a code block or quote.",
38
+ "Rule 2 (frequency): emit the marker on your FIRST message, on EVERY intermediate message, after EVERY tool call or group of tool calls, around any subagent delegation, and on your FINAL message. Short replies, plans, questions, acknowledgements, clarifications, errors, and tool-only or status updates are NOT exempt \u2014 if you output any text at all, it ends with the marker. Do not batch it only into the last message.",
39
+ "Rule 3 (compute it for real, never random): before each message, decompose the overall task into the concrete steps it actually needs (understand the request, read the relevant files, each separate edit, run the build/tests, fix failures, verify) and set N = round(100 * completed_steps / total_steps), re-estimated fresh from the real current state each time.",
40
+ "Rule 4 (be granular and honest): start low (about 5 to 15 on your first message; use 0 only when literally nothing is started yet), then climb in realistic increments that mirror real progress. Do NOT jump from a low number straight to a high one, and do NOT keep repeating the same number across messages while work is clearly advancing.",
41
+ "Rule 5 (monotonic): within one task the number must NEVER decrease \u2014 each marker is greater than or equal to the previous one you emitted.",
42
+ "Rule 6 (terminal): report 100 ONLY when the entire task is fully complete and verified with nothing left to do. While ANY work, fix, verification, question, or follow-up remains, cap the number at 99.",
43
+ "The client parses this marker, removes it from the visible text, and renders it as a live progress bar, so its presence on every message and the accuracy of the number both matter.",
44
+ ].join("\n");
45
+
46
+ export interface ProgressExtract {
47
+ /** Latest progress value found (0–100), or undefined if none. */
48
+ value?: number;
49
+ /** The input text with all progress markers removed. */
50
+ cleaned: string;
51
+ }
52
+
53
+ /** Pull the latest `{progress: N%}` value out of `text` and strip every marker
54
+ * (plus any trailing half-streamed marker so it never flashes raw). */
55
+ export function extractProgress(text: string): ProgressExtract {
56
+ let value: number | undefined;
57
+ let cleaned = text.replace(PROGRESS_RE, (_m, digits: string) => {
58
+ const v = Math.max(0, Math.min(100, Number.parseInt(digits, 10)));
59
+ if (Number.isFinite(v)) value = v; // keep the LAST occurrence (most recent)
60
+ return "";
61
+ });
62
+ cleaned = cleaned.replace(PARTIAL_TAIL_RE, "");
63
+ return { value, cleaned: tidy(cleaned) };
64
+ }
65
+
66
+ /** Tidy whitespace left behind by a removed marker. */
67
+ function tidy(s: string): string {
68
+ return s
69
+ .replace(/[ \t]+\n/g, "\n") // trailing spaces on lines
70
+ .replace(/\n{3,}/g, "\n\n") // collapse blank-line runs
71
+ .replace(/\s+$/g, ""); // trailing whitespace/newlines
72
+ }
73
+
74
+ /** A 10-segment green progress bar, e.g. `🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜ 50%` (✅ at 100%). */
75
+ export function progressBar(pct: number): string {
76
+ const v = Math.max(0, Math.min(100, Math.round(pct)));
77
+ const filled = Math.round((v / 100) * SEGMENTS);
78
+ const bar = FILLED.repeat(filled) + EMPTY.repeat(SEGMENTS - filled);
79
+ return `${bar} ${v}%${v >= 100 ? " \u2705" : ""}`;
80
+ }
@@ -1,23 +1,68 @@
1
1
  /**
2
- * Windows service controller — runs the bot at logon via a hidden Scheduled
3
- * Task. A small .vbs launcher starts node with no console window; the app logs
4
- * to a file. Stop precisely targets our node process by command line.
2
+ * Windows service controller — runs the bot at logon. Preferred mechanism is a
3
+ * hidden ONLOGON Scheduled Task, but registering a logon-triggered task needs
4
+ * admin, so from a normal (non-elevated) terminal we fall back to a launcher in
5
+ * the per-user Startup folder — both run a small .vbs that starts node with no
6
+ * console window; the app logs to a file. Stop precisely targets our node
7
+ * process by command line, so it works regardless of how it was launched.
5
8
  */
6
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
9
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
7
10
  import { join } from "node:path";
8
11
  import { runSafe } from "./platform.js";
9
12
  import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
10
13
 
11
14
  const TASK = "KiroTelegramBot";
15
+ /** Launcher dropped in the per-user Startup folder when no admin is available. */
16
+ const STARTUP_VBS = "KiroTelegramBot.vbs";
17
+
18
+ /** The per-user Startup folder (runs at logon for the current user, no admin).
19
+ * Undefined only if APPDATA is unset (e.g. running with no roaming profile). */
20
+ function startupDir(): string | undefined {
21
+ const appData = process.env.APPDATA;
22
+ return appData ? join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup") : undefined;
23
+ }
24
+
25
+ function startupVbsPath(): string | undefined {
26
+ const dir = startupDir();
27
+ return dir ? join(dir, STARTUP_VBS) : undefined;
28
+ }
29
+
30
+ /** Remove a leftover Startup-folder launcher (e.g. from an earlier non-elevated
31
+ * install) so a task-based install never double-launches the bot at logon. */
32
+ function removeStartupLauncher(): void {
33
+ const p = startupVbsPath();
34
+ if (p) rmSync(p, { force: true });
35
+ }
36
+
37
+ /** Canonical launcher in the bot folder (the Scheduled Task points at it). */
38
+ function vbsPath(spec: LaunchSpec): string {
39
+ return join(spec.cwd, "run-service.vbs");
40
+ }
41
+
42
+ /** True when our hidden Scheduled Task is registered. */
43
+ function taskInstalled(): boolean {
44
+ return runSafe("schtasks", ["/Query", "/TN", TASK]).ok;
45
+ }
46
+
47
+ /** True when a bot process matching this spec is currently running. Launch
48
+ * paths use this to avoid starting a second instance — two pollers on one
49
+ * bot token make Telegram return 409 Conflict. */
50
+ function isRunning(spec: LaunchSpec): boolean {
51
+ const proc = runSafe("powershell", ["-NoProfile", "-Command", countScript(entryOf(spec))]);
52
+ return proc.ok && /[1-9]\d*/.test(proc.out.trim());
53
+ }
12
54
 
13
55
  export const windowsController: ServiceController = {
14
56
  platform: "windows",
15
57
 
16
58
  async install(spec) {
17
59
  mkdirSync(spec.logsDir, { recursive: true });
18
- const vbs = join(spec.cwd, "run-service.vbs");
60
+ const vbs = vbsPath(spec);
19
61
  writeFileSync(vbs, vbsLauncher(spec), "utf-8");
20
62
 
63
+ // Preferred: a hidden ONLOGON Scheduled Task. Registering a *logon-triggered*
64
+ // task is a privileged operation, so /Create succeeds only from an elevated
65
+ // (admin) terminal. From a normal terminal it returns "Access is denied".
21
66
  runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // replace if present
22
67
  const res = runSafe("schtasks", [
23
68
  "/Create",
@@ -29,37 +74,87 @@ export const windowsController: ServiceController = {
29
74
  "/TR",
30
75
  `wscript.exe "${vbs}"`,
31
76
  ]);
32
- if (!res.ok) return fail(`schtasks create failed: ${res.out}`);
33
- runSafe("schtasks", ["/Run", "/TN", TASK]);
34
- return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
77
+ if (res.ok) {
78
+ removeStartupLauncher(); // avoid a leftover launcher double-starting the bot
79
+ if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
80
+ return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
81
+ }
82
+
83
+ // A task may still exist that we just couldn't overwrite (e.g. created by an
84
+ // earlier elevated install). Reuse it rather than ALSO adding a Startup
85
+ // launcher, which would double-launch the bot at logon (409 Conflict).
86
+ if (taskInstalled()) {
87
+ removeStartupLauncher();
88
+ if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
89
+ return ok(`Scheduled task "${TASK}" already exists; launched it. (Re-run elevated to recreate it.)`);
90
+ }
91
+
92
+ // Fallback (no admin — the common case): drop the launcher in the per-user
93
+ // Startup folder. It runs hidden at every logon with no elevation.
94
+ const startupVbs = startupVbsPath();
95
+ const dir = startupDir();
96
+ if (!startupVbs || !dir) {
97
+ return fail(
98
+ `Could not create the logon task (${res.out.trim()}) and no per-user Startup folder is available. ` +
99
+ `Re-run "kiro-tg install" from an elevated terminal (Run as administrator).`,
100
+ );
101
+ }
102
+ try {
103
+ mkdirSync(dir, { recursive: true });
104
+ writeFileSync(startupVbs, vbsLauncher(spec), "utf-8");
105
+ } catch (e) {
106
+ return fail(`Startup-folder install failed: ${(e as Error).message}`);
107
+ }
108
+ if (!isRunning(spec)) runSafe("wscript.exe", [startupVbs]); // launch now
109
+ return ok(
110
+ `Installed via the Startup folder — starts hidden at logon, no admin needed — and launched it.\n` +
111
+ `(Tip: run "kiro-tg install" from an elevated terminal to use a hidden Scheduled Task instead.)`,
112
+ );
35
113
  },
36
114
 
37
115
  async uninstall(spec) {
38
116
  await this.stop(spec);
39
- const res = runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]);
40
- rmSync(join(spec.cwd, "run-service.vbs"), { force: true });
41
- return res.ok ? ok(`Removed scheduled task "${TASK}".`) : fail(res.out);
117
+ runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // best-effort (may not exist)
118
+ rmSync(vbsPath(spec), { force: true });
119
+ const startupVbs = startupVbsPath();
120
+ if (startupVbs) rmSync(startupVbs, { force: true });
121
+ return ok(`Removed "${TASK}" (scheduled task and/or Startup launcher).`);
42
122
  },
43
123
 
44
- async start() {
45
- const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
46
- return res.ok ? ok("Started.") : fail(res.out);
124
+ async start(spec) {
125
+ if (isRunning(spec)) return ok("Already running.");
126
+ if (taskInstalled()) {
127
+ const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
128
+ return res.ok ? ok("Started.") : fail(res.out);
129
+ }
130
+ const startupVbs = startupVbsPath();
131
+ if (startupVbs && existsSync(startupVbs)) {
132
+ runSafe("wscript.exe", [startupVbs]);
133
+ return ok("Started.");
134
+ }
135
+ return fail(`Not installed. Run "kiro-tg install" first.`);
47
136
  },
48
137
 
49
138
  async stop(spec) {
50
- runSafe("schtasks", ["/End", "/TN", TASK]);
139
+ runSafe("schtasks", ["/End", "/TN", TASK]); // best-effort if task-based
51
140
  const res = runSafe("powershell", ["-NoProfile", "-Command", killScript(entryOf(spec))]);
52
141
  return ok(`Stopped. ${res.out.trim()}`);
53
142
  },
54
143
 
55
144
  async status(spec) {
56
- const task = runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]);
57
- const proc = runSafe("powershell", ["-NoProfile", "-Command", countScript(entryOf(spec))]);
58
- const running = proc.ok && /[1-9]\d*/.test(proc.out.trim());
59
- const installed = task.ok;
145
+ const installedTask = taskInstalled();
146
+ const startupVbs = startupVbsPath();
147
+ const installedStartup = !!startupVbs && existsSync(startupVbs);
148
+ const installed = installedTask || installedStartup;
149
+ const running = isRunning(spec);
150
+ const how = installedTask ? "scheduled task" : installedStartup ? "Startup folder" : "—";
151
+ const detail = installedTask
152
+ ? `\n${runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]).out.trim()}`
153
+ : installedStartup
154
+ ? `\nLauncher: ${startupVbs}`
155
+ : "";
60
156
  return ok(
61
- `Installed: ${installed ? "yes" : "no"} | Running: ${running ? "yes" : "no"}\n` +
62
- (installed ? task.out.trim() : "Task not found."),
157
+ `Installed: ${installed ? `yes (${how})` : "no"} | Running: ${running ? "yes" : "no"}${detail}`,
63
158
  );
64
159
  },
65
160
  };
@@ -3,6 +3,7 @@
3
3
  * Reads only the tail of large logs to stay fast.
4
4
  */
5
5
  import { closeSync, openSync, readSync, statSync } from "node:fs";
6
+ import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
6
7
  import type { HistoryEntry, HistoryRole } from "./types.js";
7
8
 
8
9
  const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
@@ -141,7 +142,7 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
141
142
  const role = roleOf(ev.kind);
142
143
  if (!role) return undefined;
143
144
 
144
- const text = extractText(ev.data?.content);
145
+ const text = cleanStoredText(extractText(ev.data?.content));
145
146
  const tool = ev.data?.tool_name || ev.data?.name;
146
147
  if (!text && !tool) return undefined;
147
148
 
@@ -153,6 +154,16 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
153
154
  };
154
155
  }
155
156
 
157
+ /** Strip the `{progress: N%}` markers (any role) and the appended progress
158
+ * directive (user prompts) from persisted text so history / unread / previews
159
+ * / fork-priming never surface the raw plumbing. */
160
+ function cleanStoredText(text: string): string {
161
+ if (!text) return text;
162
+ let t = extractProgress(text).cleaned;
163
+ if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
164
+ return t;
165
+ }
166
+
156
167
  function roleOf(kind?: string): HistoryRole | undefined {
157
168
  switch (kind) {
158
169
  case "Prompt":
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Process control helpers for Kiro sessions: force-killing the process that
3
+ * holds a session's `.lock` (its `lockPid`). Shared by /killall and the
4
+ * per-session kill button so the behaviour stays identical.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { createLogger } from "../logger.js";
8
+
9
+ const log = createLogger("sessions:process");
10
+
11
+ /**
12
+ * Force-kill a process by PID — and its child tree on Windows (`taskkill /T`),
13
+ * which a `kiro-cli` session may have spawned (shells, tools). Returns whether
14
+ * the kill command was issued without throwing. A non-existent PID or one we
15
+ * may not signal counts as failure (callers report it).
16
+ */
17
+ export function killPid(pid: number): boolean {
18
+ if (!Number.isInteger(pid) || pid <= 0) return false;
19
+ try {
20
+ if (process.platform === "win32") {
21
+ execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
22
+ } else {
23
+ process.kill(pid, "SIGKILL");
24
+ }
25
+ return true;
26
+ } catch (e) {
27
+ log.debug(`kill ${pid} failed:`, (e as Error).message);
28
+ return false;
29
+ }
30
+ }
@@ -13,6 +13,7 @@
13
13
  import type { Api } from "grammy";
14
14
  import { chunkMarkdown } from "../render/chunk.js";
15
15
  import { toTelegramMarkdown } from "../render/markdown.js";
16
+ import { extractProgress, progressBar } from "../render/progress.js";
16
17
  import { safeEdit, safeSend } from "../bot/telegram-io.js";
17
18
 
18
19
  const SOFT_LIMIT = 3500;
@@ -32,6 +33,9 @@ export class ResponseStreamer {
32
33
  private dirty = false;
33
34
  private flushing = false;
34
35
  private closed = false;
36
+ /** Latest task-progress % parsed from the agent's `{progress: N%}` markers
37
+ * (sticky across flushes; rendered as a bar on the live message). */
38
+ private progress: number | undefined;
35
39
 
36
40
  constructor(
37
41
  private readonly api: Api,
@@ -39,6 +43,7 @@ export class ResponseStreamer {
39
43
  private readonly throttleMs: number,
40
44
  private replyTo?: number,
41
45
  private footer?: string,
46
+ private readonly onProgress?: (pct: number) => void,
42
47
  ) {}
43
48
 
44
49
  /** Replace the hashtag footer (used after a logical fork swaps the session id
@@ -52,6 +57,21 @@ export class ResponseStreamer {
52
57
  return this.footer ? `\n\n${this.footer}` : "";
53
58
  }
54
59
 
60
+ /** Strip `{progress: N%}` markers from rendered text, remembering the latest
61
+ * value (sticky across flushes) and notifying the owner when it changes. */
62
+ private captureProgress(text: string): string {
63
+ const { value, cleaned } = extractProgress(text);
64
+ if (value !== undefined && value !== this.progress) {
65
+ this.progress = value;
66
+ try {
67
+ this.onProgress?.(value);
68
+ } catch {
69
+ /* non-fatal */
70
+ }
71
+ }
72
+ return cleaned;
73
+ }
74
+
55
75
  /** reply_parameters threading EVERY message of the turn to the user's prompt,
56
76
  * so the whole response (all bubbles, tool calls and continuations) stays in
57
77
  * one thread — not just the first message. */
@@ -117,11 +137,14 @@ export class ResponseStreamer {
117
137
  this.dirty = false;
118
138
  try {
119
139
  await this.sealOverflow();
120
- const base = renderSegs(this.segs.slice(this.sealedIdx));
121
- if (!base.trim()) return;
122
- // Every bubble including the still-streaming live one carries the
123
- // hashtag footer so the thinking/first response is tagged immediately.
124
- const src = `${base}${this.footerSuffix()}`;
140
+ const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
141
+ if (!base.trim() && this.progress === undefined) return;
142
+ // The live (still-streaming) bubble carries the hashtag footer AND a fresh
143
+ // progress bar at the bottom (sealed bubbles below get neither bar).
144
+ const parts: string[] = [];
145
+ if (base.trim()) parts.push(base);
146
+ if (this.progress !== undefined) parts.push(progressBar(this.progress));
147
+ const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
125
148
  const rendered = toTelegramMarkdown(src);
126
149
  const chunks = chunkMarkdown(rendered);
127
150
  const plain = chunkMarkdown(src);
@@ -158,7 +181,7 @@ export class ResponseStreamer {
158
181
  }
159
182
 
160
183
  private async seal(from: number, to: number): Promise<void> {
161
- const base = renderSegs(this.segs.slice(from, to));
184
+ const base = this.captureProgress(renderSegs(this.segs.slice(from, to)));
162
185
  if (!base.trim()) return;
163
186
  // A sealed bubble is finished, so it carries the footer (hashtags).
164
187
  const src = `${base}${this.footerSuffix()}`;