kiro-telegram-bot 1.7.1 → 1.7.2
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/.env.example +11 -0
- package/CHANGELOG.md +87 -0
- package/README.md +35 -12
- package/package.json +1 -1
- package/scripts/setup.mjs +51 -11
- package/src/acp/client.ts +25 -2
- package/src/app/instance-lock.ts +139 -0
- package/src/bot/auth.ts +14 -3
- package/src/bot/bot.ts +5 -0
- package/src/bot/handlers/control.ts +2 -2
- package/src/bot/menu/status-panel.ts +51 -17
- package/src/bot/session-runtime.ts +17 -7
- package/src/cli.ts +5 -4
- package/src/config.ts +33 -14
- package/src/index.ts +15 -1
- package/src/render/progress-estimate.ts +63 -0
- package/src/stream/streamer.ts +56 -11
package/.env.example
CHANGED
|
@@ -61,6 +61,17 @@ AGENT_IMAGES_MAX=8
|
|
|
61
61
|
# so the chat isn't silent during delegated/parallel work. true/false
|
|
62
62
|
SHOW_SUBAGENTS=true
|
|
63
63
|
|
|
64
|
+
# Task-progress bar. SHOW_PROGRESS asks the agent to end each message with a
|
|
65
|
+
# {progress: N%} marker, which the bot hides and renders as a green 0–100% bar
|
|
66
|
+
# on the live message, session cards, and the status panel.
|
|
67
|
+
# That marker is only an instruction the model can ignore (common on long,
|
|
68
|
+
# tool-heavy turns or weaker/free models), leaving the bar empty. PROGRESS_FALLBACK
|
|
69
|
+
# then shows a bot-computed bar derived from REAL activity (completed tool calls,
|
|
70
|
+
# streamed output, elapsed time) whenever the agent emits no marker, so a live bar
|
|
71
|
+
# still advances. The agent's own marker, when present, always takes precedence.
|
|
72
|
+
SHOW_PROGRESS=true
|
|
73
|
+
PROGRESS_FALLBACK=true
|
|
74
|
+
|
|
64
75
|
# When you control several sessions at once and switch between them, deliver a
|
|
65
76
|
# session's "Done" summary even while that session is in the background (you're
|
|
66
77
|
# viewing another one) — clearly marked "From other session" with a short
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,93 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
7
7
|
The latest section is published verbatim as the GitHub Release notes by
|
|
8
8
|
`.github/workflows/release.yml` when a `vX.Y.Z` tag is pushed.
|
|
9
9
|
|
|
10
|
+
## [1.7.2] - 2026-06-25
|
|
11
|
+
|
|
12
|
+
The **"steady & solo"** release — a self-computing progress bar that never spams
|
|
13
|
+
empty bubbles, a single-instance guard that clears ghost processes, a
|
|
14
|
+
path-independent `~/.kiro/tg/` config home, a polished pinned status panel, and
|
|
15
|
+
fixes for the false idle-timeout during subagent (translation) work and the bot
|
|
16
|
+
rejecting its own pin messages as "Not authorized".
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **📈 Bot-computed task-progress fallback (`PROGRESS_FALLBACK`).** The
|
|
21
|
+
`{progress: N%}` bar previously depended entirely on the agent emitting the
|
|
22
|
+
marker — and that marker is only an *instruction* the model can ignore, so
|
|
23
|
+
weaker/free models and long, tool-heavy turns often emitted none, leaving the
|
|
24
|
+
bar empty for the whole turn. Now, when `SHOW_PROGRESS` is on but no marker
|
|
25
|
+
arrives, the bot renders a **computed** bar derived from **real activity**
|
|
26
|
+
(completed tool calls, streamed output, elapsed time): it starts low, climbs in
|
|
27
|
+
realistic increments via a saturating curve capped at 90 % while running, and
|
|
28
|
+
fills to 100 % when the turn completes successfully. The estimate is monotonic
|
|
29
|
+
by construction, and the agent's own marker — when present — always takes
|
|
30
|
+
precedence (the fallback stops contributing the moment a real value arrives).
|
|
31
|
+
The bar is only ever **appended to real streamed content** — it never produces
|
|
32
|
+
a standalone/empty bubble — and the live status panel shows it on its own.
|
|
33
|
+
Disable with `PROGRESS_FALLBACK=false`.
|
|
34
|
+
- **🏠 Canonical, path-independent config home (`~/.kiro/tg/`).** The `.env`
|
|
35
|
+
(plus `logs/`, `data/`) now lives in `~/.kiro/tg/` by default, so the bot loads
|
|
36
|
+
the **same** configuration no matter which folder you start it from — no more
|
|
37
|
+
"works from this directory, broken from that one". Resolution order is
|
|
38
|
+
`--instance` → `KIRO_TG_DIR` → a `.env` in the current folder (so existing
|
|
39
|
+
per-folder checkouts keep working) → `~/.kiro/tg`. `kiro-tg setup` writes there
|
|
40
|
+
by default, and **`kiro-tg setup --path`** prints the resolved `.env` location.
|
|
41
|
+
- **🔒 Single-instance guard, per bot token (`KIRO_TG_SINGLE_INSTANCE`).** On
|
|
42
|
+
startup the bot takes a token-scoped lock under `~/.kiro/tg/locks/`; if a
|
|
43
|
+
still-alive **ghost/duplicate** is already polling Telegram with that token, it
|
|
44
|
+
is terminated (and its child tree on Windows) so the fresh process — with your
|
|
45
|
+
current `.env` — becomes the sole `getUpdates` consumer.
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **🧭 Polished status panel.** The pinned status message was redesigned for
|
|
50
|
+
readability: the redundant "Kiro — Status" header is gone, the **progress bar
|
|
51
|
+
is the first line** (so the collapsed pin preview shows how far along the
|
|
52
|
+
current task is), and the cramped space-padded columns are replaced with clean
|
|
53
|
+
emoji-led fields separated by ` | ` across three short lines — activity
|
|
54
|
+
(`state | queue | sessions | watching | subagents`), location
|
|
55
|
+
(`project | session | context`) and config (`agent | reasoning | model`).
|
|
56
|
+
Counters that don't apply (empty queue, single session) are hidden instead of
|
|
57
|
+
shown as `0`.
|
|
58
|
+
- **🧹 Progress clears when a turn ends.** The task-progress value is now reset
|
|
59
|
+
when a turn finishes, stops, or errors, so the bar is removed from the status
|
|
60
|
+
panel, session cards and switch messages once the work is done (the finished
|
|
61
|
+
streamed message keeps its own frozen bar as a record).
|
|
62
|
+
- **🫥 Status panel only while working.** The pinned status panel now appears
|
|
63
|
+
while a turn is running (or a follow-up is queued) and is **removed when the
|
|
64
|
+
session goes idle**, so the chat stays clean between tasks. The full state is
|
|
65
|
+
still available on demand via **Status** in the menu (`/status`).
|
|
66
|
+
|
|
67
|
+
### Fixed
|
|
68
|
+
|
|
69
|
+
- **⛔ Spurious "Not authorized" from the bot's own pin messages.** The auth
|
|
70
|
+
gate replied "⛔ Not authorized" to **every** update whose sender wasn't an
|
|
71
|
+
allowed user — including the bot's **own** service messages. Since the status
|
|
72
|
+
panel is pinned/unpinned, each pin emits a `pinned_message` service update
|
|
73
|
+
authored by the bot, so the gate kept rejecting itself (interleaved with
|
|
74
|
+
normal replies). The gate now ignores updates that aren't a real user action
|
|
75
|
+
(the bot's own/`is_bot` updates, service messages, and updates with no
|
|
76
|
+
`from`), and those pin service messages are deleted on arrival so they no
|
|
77
|
+
longer clutter the chat. Genuine unauthorized users still get one clear reply.
|
|
78
|
+
- **⛔ Phantom "Not authorized" from a ghost process.** A leftover bot started
|
|
79
|
+
from another folder kept answering with a stale `.env` (e.g. an outdated
|
|
80
|
+
`ALLOWED_USERS`), rejecting you while the new process couldn't poll (Telegram
|
|
81
|
+
409 Conflict). The single-instance guard above clears the ghost on startup. A
|
|
82
|
+
plain `kiro-tg run` still **yields** to an already-running background service
|
|
83
|
+
rather than fighting it (no restart/kill loop).
|
|
84
|
+
- **⏱️ False "No agent activity … giving up" during subagent delegation.** The
|
|
85
|
+
prompt idle-timeout tracked activity per session, but subagents (e.g. parallel
|
|
86
|
+
translation crews) stream on their own session ids, so a main turn that
|
|
87
|
+
delegated heavy work looked "silent" and was killed after ~15 min even though
|
|
88
|
+
the agent was busy — and the next message then collided with the still-running
|
|
89
|
+
turn as `-32603 … dispatch failure`. The watchdog now uses a **process-wide
|
|
90
|
+
activity clock** (any session/subagent stream, metadata, or subagent status
|
|
91
|
+
refreshes it), so a delegating turn stays alive while its subagents work; only
|
|
92
|
+
a genuinely silent agent trips it. When it does fire (idle or the hard cap),
|
|
93
|
+
the agent's turn is now **cancelled** so the session is immediately reusable.
|
|
94
|
+
`dispatch failure` and common connection/stream errors are also now classified
|
|
95
|
+
as **transient**, so they retry/auto-fork instead of surfacing as a dead end.
|
|
96
|
+
|
|
10
97
|
## [1.7.1] - 2026-06-24
|
|
11
98
|
|
|
12
99
|
The **"sign in your way"** release — `/reauth` now lets you pick how you log in
|
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ and extended into a full multi-session client.
|
|
|
29
29
|
| 🟢 **Connect to live sessions** | `/active` shows sessions running **right now** on your PC. Watch them live, or continue them — see below. |
|
|
30
30
|
| 🛑 **Kill a session / PID** | Each live `/sessions` · `/active` card has a **🛑 Kill · pid N** button (confirm-guarded) that stops that session's process and its child tree; `/killall` stops them all. The bot's own agent is never killable. |
|
|
31
31
|
| 📡 **Live watch** | Follow a running session read-only in real time (tails its event log). |
|
|
32
|
-
| 🧭 **Always-visible menu** | A persistent keyboard plus a pinned status panel that
|
|
32
|
+
| 🧭 **Always-visible menu** | A persistent keyboard plus a pinned status panel that appears while a task runs (and clears when idle), showing your current **project, agent, reasoning effort, model, session and queue**. |
|
|
33
33
|
| ⏰ **Scheduled tasks** | Create prompts that run on a schedule (once / daily / weekly / monthly / every-N-minutes) in a chosen project, delivered back to your chat. |
|
|
34
34
|
| 🖼 **Multi-image prompts** | Send one or many photos (albums included) with a caption — all attached to the prompt for the agent to analyze. |
|
|
35
35
|
| 📜 **History** | `/history` shows the latest messages of any session. |
|
|
@@ -81,20 +81,29 @@ the `tsx` runtime, no build step):
|
|
|
81
81
|
npm install -g kiro-telegram-bot
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
By default your config lives in a **canonical, path-independent home** —
|
|
85
|
+
`~/.kiro/tg/` (its `.env`, `logs/`, `data/`) — so the bot loads the **same**
|
|
86
|
+
`.env` no matter which folder you start it from. Run `kiro-tg setup --path` to
|
|
87
|
+
print the exact location. (A `.env` in the current folder is still honoured
|
|
88
|
+
first, so existing per-folder checkouts keep working.)
|
|
86
89
|
|
|
87
90
|
```bash
|
|
88
|
-
|
|
89
|
-
kiro-tg setup
|
|
90
|
-
# edit .env: set TELEGRAM_BOT_TOKEN and ALLOWED_USERS
|
|
91
|
+
kiro-tg setup # auto-detects kiro-cli, writes ~/.kiro/tg/.env
|
|
92
|
+
kiro-tg setup --path # print the .env location
|
|
93
|
+
# edit that .env: set TELEGRAM_BOT_TOKEN and ALLOWED_USERS
|
|
91
94
|
kiro-tg run # foreground …
|
|
92
95
|
kiro-tg install # … or install as a 24/7 background service
|
|
93
96
|
```
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
The bot is **single-instance per token**: starting it again terminates any
|
|
99
|
+
ghost/duplicate that was still polling Telegram (the usual cause of a stale
|
|
100
|
+
"⛔ Not authorized"), so the fresh process with your current `.env` wins. A
|
|
101
|
+
plain `kiro-tg run` yields to an already-running background service instead.
|
|
102
|
+
|
|
103
|
+
Startup options: `kiro-tg setup [--path] | run | install | status | logs [n] |
|
|
104
|
+
stop | restart | uninstall`. Or try it without installing: `npx
|
|
105
|
+
kiro-telegram-bot setup`. See **[docs/INSTALL.md](./docs/INSTALL.md)** for the
|
|
106
|
+
full guide.
|
|
98
107
|
|
|
99
108
|
---
|
|
100
109
|
|
|
@@ -211,9 +220,11 @@ A tiny **persistent bar** sits under the message box — **☰ Menu · 🧭 Runn
|
|
|
211
220
|
Sessions · Agent · Model · Reasoning · Tasks · Status · Usage · Stop · Kill all.
|
|
212
221
|
The bar can be hidden (🙈) and restored (⌨️ Show bar or `/menu`).
|
|
213
222
|
|
|
214
|
-
|
|
215
|
-
**
|
|
216
|
-
|
|
223
|
+
While a task is running, a **pinned status panel** appears at the top of the chat
|
|
224
|
+
showing your current **task progress, activity, queue, project, session, context
|
|
225
|
+
%, agent, reasoning effort and model** (and how many sessions the chat controls),
|
|
226
|
+
updating live — and it's **removed when the session goes idle** so the chat stays
|
|
227
|
+
clean between tasks (use **Status** in the menu to see it on demand any time).
|
|
217
228
|
Pick **Agent**, **Reasoning** or **Model** from the inline menu (reasoning steers
|
|
218
229
|
how thoroughly the agent works: Minimal → Max).
|
|
219
230
|
|
|
@@ -260,6 +271,15 @@ pinned **status panel**, and on **`/running` and `/sessions` cards**. Markers ar
|
|
|
260
271
|
also stripped from history, replays and previews, so the raw plumbing never
|
|
261
272
|
shows. Turn it off with `SHOW_PROGRESS=false`.
|
|
262
273
|
|
|
274
|
+
That marker is only an instruction the model can ignore — weaker/free models and
|
|
275
|
+
long, tool-heavy turns often emit none, which used to leave the bar empty for the
|
|
276
|
+
whole turn. So when `SHOW_PROGRESS` is on but no marker arrives, the bot falls
|
|
277
|
+
back to a **computed** bar derived from real activity (completed tool calls,
|
|
278
|
+
streamed output, elapsed time): it starts low, climbs as work advances, and fills
|
|
279
|
+
to 100 % when the turn completes. The agent's own marker, when present, always
|
|
280
|
+
takes precedence and the value never decreases. Disable the fallback with
|
|
281
|
+
`PROGRESS_FALLBACK=false`.
|
|
282
|
+
|
|
263
283
|
## 🔐 Re-authenticating Kiro
|
|
264
284
|
|
|
265
285
|
Run **`/reauth`** to log out and start a fresh **device-flow** login without
|
|
@@ -307,6 +327,7 @@ Resuming an **idle** session loads it directly so you continue the exact thread.
|
|
|
307
327
|
| `ALLOWED_USERS` | recommended | *(all)* | Comma-separated Telegram user IDs. Empty = anyone (unsafe). |
|
|
308
328
|
| `KIRO_CLI_PATH` | no | auto / `kiro-cli` | Path to the `kiro-cli` binary. |
|
|
309
329
|
| `KIRO_WORKSPACE` | no | cwd | Default working directory. |
|
|
330
|
+
| `KIRO_TG_DIR` | no | `~/.kiro/tg` | Folder holding this instance's `.env`, `logs/`, `data/`. Resolution: `--instance` → `KIRO_TG_DIR` → a `.env` in the current folder → `~/.kiro/tg`. So a `.env` created once is loaded from any startup path. |
|
|
310
331
|
| `KIRO_AGENT` | no | — | Custom agent from `.kiro/agents/`. |
|
|
311
332
|
| `KIRO_TRUST_ALL_TOOLS` | no | `true` | Run tools without prompts. |
|
|
312
333
|
| `PROJECT_ROOTS` | no | workspace parent + home | Roots for `/projects`. |
|
|
@@ -317,10 +338,12 @@ Resuming an **idle** session loads it directly so you continue the exact thread.
|
|
|
317
338
|
| `DIFF_MAX_LINES` | no | `120` | Max diff lines shown inline. |
|
|
318
339
|
| `SHOW_SUBAGENTS` | no | `true` | Stream subagent (crew) start/work/finish while the main agent waits. |
|
|
319
340
|
| `SHOW_PROGRESS` | no | `true` | Ask the agent to append a `{progress: N%}` marker to each message; the bot parses it, hides the marker, and renders a green 0–100% bar on the live message, in session cards, and in the status panel. |
|
|
341
|
+
| `PROGRESS_FALLBACK` | no | `true` | When `SHOW_PROGRESS` is on but the agent emits **no** `{progress: N%}` marker (weaker/free models and long tool-heavy turns often skip it), render a **bot-computed** bar derived from real activity (completed tool calls, streamed output, elapsed time) so a live bar still advances — filling to 100% when the turn completes. The agent's own marker, when present, always takes precedence and stays monotonic. |
|
|
320
342
|
| `NOTIFY_OTHER_SESSIONS` | no | `true` | Deliver a session's "Done" summary (with a short created/edited/deleted count) even when it's a background session, marked "From other session". `false` keeps background sessions silent. |
|
|
321
343
|
| `MCP_PROBE_TIMEOUT_MS` | no | `8000` | Per-server timeout for the `/mcp` live health-check. |
|
|
322
344
|
| `MCP_PROBE_CONCURRENCY` | no | `6` | How many MCP health probes run at once. |
|
|
323
345
|
| `ACP_AUTO_RESTART` | no | `true` | Auto-restart the agent if it exits. |
|
|
346
|
+
| `KIRO_TG_SINGLE_INSTANCE` | no | `true` | Enforce one running bot **per token**: on startup a still-alive ghost/duplicate (an old process polling Telegram with a stale `.env`, the usual cause of a phantom "⛔ Not authorized") is terminated so the fresh process wins. A manual `run` yields to an already-running background service instead of fighting it. |
|
|
324
347
|
| `AUTO_UPDATE` | no | `true` | Hourly check npm and, when a newer version exists **and the bot is idle** (no turn/task running, no other active Kiro session), auto-update + restart + post the release notes (tagged `#update`). Global npm installs only. |
|
|
325
348
|
| `UPDATE_CHECK_MS` | no | `3600000` | How often to check npm for updates (ms). |
|
|
326
349
|
| `PROMPT_RETRY_ATTEMPTS` | no | `5` | Max retries for a transient agent error (e.g. high-traffic / `Internal error`) before any output streamed, with `6s → 12s → 24s → 48s → 60s` backoff. The real error shows each attempt; a summary after the last. `0` disables. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kiro-telegram-bot",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.2",
|
|
4
4
|
"description": "Control Kiro CLI from Telegram over the Agent Client Protocol (ACP). Switch projects, resume and attach to live coding sessions, stream responses with diffs, queue follow-ups, and run 24/7 as a cross-platform background service.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
package/scripts/setup.mjs
CHANGED
|
@@ -1,24 +1,63 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Easy setup: creates
|
|
4
|
-
* and sensible PROJECT_ROOTS, and optionally writes the bot token / user id
|
|
5
|
-
* passed as arguments:
|
|
3
|
+
* Easy setup: creates/updates the bot's .env, auto-detects the kiro-cli binary
|
|
4
|
+
* and sensible PROJECT_ROOTS, and optionally writes the bot token / user id:
|
|
6
5
|
*
|
|
7
|
-
* node scripts/setup.mjs <TELEGRAM_BOT_TOKEN> [ALLOWED_USER_ID]
|
|
6
|
+
* node scripts/setup.mjs [--path] [--instance <dir>] [<TELEGRAM_BOT_TOKEN> [ALLOWED_USER_ID]]
|
|
7
|
+
*
|
|
8
|
+
* By default the .env lives in the canonical, path-independent home
|
|
9
|
+
* `~/.kiro/tg/.env`, so the bot loads the SAME config no matter where it's
|
|
10
|
+
* started from. A `.env` already present in the current folder (an explicit
|
|
11
|
+
* per-folder checkout) is used instead. `--path` just prints the resolved .env
|
|
12
|
+
* path and exits (nothing is written).
|
|
8
13
|
*/
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
15
|
import { homedir } from "node:os";
|
|
11
|
-
import { join,
|
|
16
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
17
|
import { fileURLToPath } from "node:url";
|
|
13
18
|
|
|
14
19
|
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
-
// .env lives in the instance dir (the user's folder); the template ships in the
|
|
16
|
-
// package. For a cloned/zip checkout run in place these are the same folder.
|
|
17
|
-
const instanceDir = process.env.KIRO_TG_CWD?.trim() || process.cwd();
|
|
18
|
-
const envPath = join(instanceDir, ".env");
|
|
19
20
|
const examplePath = join(root, ".env.example");
|
|
21
|
+
const CANONICAL_DIR = join(homedir(), ".kiro", "tg");
|
|
22
|
+
|
|
23
|
+
function expandHome(p) {
|
|
24
|
+
if (p === "~") return homedir();
|
|
25
|
+
if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Mirror of config.ts resolveInstanceDir() so setup writes EXACTLY where the
|
|
30
|
+
* bot will read from. Keep the two in sync. */
|
|
31
|
+
function resolveInstanceDir() {
|
|
32
|
+
const flag = process.argv.indexOf("--instance");
|
|
33
|
+
if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]);
|
|
34
|
+
const envDir = (process.env.KIRO_TG_DIR || process.env.KIRO_TG_CWD || "").trim();
|
|
35
|
+
if (envDir) return resolve(expandHome(envDir));
|
|
36
|
+
if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
|
|
37
|
+
return CANONICAL_DIR;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Parse args: flags (--path, --instance <dir>) vs positional token/user.
|
|
41
|
+
const argv = process.argv.slice(2);
|
|
42
|
+
let pathOnly = false;
|
|
43
|
+
const positionals = [];
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a === "--path") pathOnly = true;
|
|
47
|
+
else if (a === "--instance") i++; // value consumed by resolveInstanceDir()
|
|
48
|
+
else positionals.push(a);
|
|
49
|
+
}
|
|
50
|
+
const [tokenArg, userArg] = positionals;
|
|
51
|
+
|
|
52
|
+
const instanceDir = resolveInstanceDir();
|
|
53
|
+
const envPath = join(instanceDir, ".env");
|
|
54
|
+
|
|
55
|
+
if (pathOnly) {
|
|
56
|
+
console.log(envPath);
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
20
59
|
|
|
21
|
-
|
|
60
|
+
mkdirSync(instanceDir, { recursive: true });
|
|
22
61
|
|
|
23
62
|
function detectKiro() {
|
|
24
63
|
const candidates = [
|
|
@@ -70,6 +109,7 @@ if (userArg) {
|
|
|
70
109
|
|
|
71
110
|
writeFileSync(envPath, env, "utf-8");
|
|
72
111
|
console.log(`\n✓ .env written to ${envPath}`);
|
|
112
|
+
console.log(" (loaded from here no matter which folder you start the bot in)");
|
|
73
113
|
|
|
74
114
|
if (!/^TELEGRAM_BOT_TOKEN=.+/m.test(env)) {
|
|
75
115
|
console.log("\nNext: open .env and paste your bot token from @BotFather, then run `kiro-tg run` (or `npm start`).");
|
package/src/acp/client.ts
CHANGED
|
@@ -29,7 +29,7 @@ const log = createLogger("acp:client");
|
|
|
29
29
|
/** JSON-RPC error codes that usually mean "transient backend hiccup". */
|
|
30
30
|
const TRANSIENT_CODES = new Set([-32603, -32500, -32000, 500, 502, 503, 504, 429]);
|
|
31
31
|
const TRANSIENT_RE =
|
|
32
|
-
/internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|\b50[234]\b|\b429\b/i;
|
|
32
|
+
/internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
|
|
33
33
|
|
|
34
34
|
/** Error that preserves the agent's JSON-RPC error code and data payload. */
|
|
35
35
|
export class AcpError extends Error {
|
|
@@ -120,6 +120,12 @@ export class AcpClient extends EventEmitter {
|
|
|
120
120
|
private readonly promptMaxMs: number;
|
|
121
121
|
/** Last time we saw streaming activity for a session (epoch ms). */
|
|
122
122
|
private readonly lastActivity = new Map<string, number>();
|
|
123
|
+
/** Last time ANY session OR subagent produced output (epoch ms) — a
|
|
124
|
+
* process-wide "the agent is alive" clock. The per-session map misses work
|
|
125
|
+
* done by subagents (they stream on their own session ids), so without this
|
|
126
|
+
* a prompt that delegates to long-running subagents (e.g. parallel
|
|
127
|
+
* translation) trips the idle timeout while its subagents are doing the work. */
|
|
128
|
+
private lastActivityAny = 0;
|
|
123
129
|
private stopped = false;
|
|
124
130
|
private restartAttempts = 0;
|
|
125
131
|
private restartTimer?: NodeJS.Timeout;
|
|
@@ -283,15 +289,21 @@ export class AcpClient extends EventEmitter {
|
|
|
283
289
|
const start = Date.now();
|
|
284
290
|
this.lastActivity.set(sessionId, start);
|
|
285
291
|
const watch = setInterval(() => {
|
|
286
|
-
|
|
292
|
+
// Count activity from ANY session/subagent (the process-wide clock), so
|
|
293
|
+
// a turn that's delegating to long-running subagents stays alive while
|
|
294
|
+
// they work — only a genuinely silent (stuck) agent trips the timeout.
|
|
295
|
+
const last = Math.max(this.lastActivity.get(sessionId) ?? start, this.lastActivityAny);
|
|
296
|
+
const idle = Date.now() - last;
|
|
287
297
|
const total = Date.now() - start;
|
|
288
298
|
if (total > this.promptMaxMs) {
|
|
289
299
|
this.pending.delete(id);
|
|
290
300
|
clearInterval(watch);
|
|
301
|
+
void this.cancel(sessionId); // stop the runaway turn so the session is reusable
|
|
291
302
|
reject(new Error(`Prompt exceeded the ${Math.round(this.promptMaxMs / 60_000)}min cap`));
|
|
292
303
|
} else if (idle > this.promptIdleMs) {
|
|
293
304
|
this.pending.delete(id);
|
|
294
305
|
clearInterval(watch);
|
|
306
|
+
void this.cancel(sessionId); // free the session — otherwise the next prompt collides ("dispatch failure")
|
|
295
307
|
reject(new Error(`No agent activity for ${Math.round(idle / 1000)}s — giving up`));
|
|
296
308
|
}
|
|
297
309
|
}, 15_000);
|
|
@@ -496,6 +508,17 @@ export class AcpClient extends EventEmitter {
|
|
|
496
508
|
}
|
|
497
509
|
|
|
498
510
|
private routeNotification(method: string, params: unknown): void {
|
|
511
|
+
// Any agent-work notification — the main stream, a SUBAGENT's stream, model
|
|
512
|
+
// metadata, or a subagent status change — proves the process is alive, so
|
|
513
|
+
// refresh the process-wide clock. This is what keeps a prompt delegating to
|
|
514
|
+
// long-running subagents from tripping the per-session idle timeout.
|
|
515
|
+
if (
|
|
516
|
+
method === "session/update" ||
|
|
517
|
+
method === "_kiro.dev/metadata" ||
|
|
518
|
+
method === "_kiro.dev/subagent/list_update"
|
|
519
|
+
) {
|
|
520
|
+
this.lastActivityAny = Date.now();
|
|
521
|
+
}
|
|
499
522
|
if (method === "session/update") {
|
|
500
523
|
const p = params as SessionNotificationParams;
|
|
501
524
|
if (p?.sessionId && p.update) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-instance guard, keyed per bot token (NOT per folder), so the same bot
|
|
3
|
+
* can't run twice no matter which directory it's started from.
|
|
4
|
+
*
|
|
5
|
+
* Telegram allows only ONE long-polling consumer per token — a second instance
|
|
6
|
+
* triggers 409 Conflict and, worse, a leftover "ghost" process started from an
|
|
7
|
+
* old folder keeps answering with a stale `.env` (e.g. an outdated
|
|
8
|
+
* `ALLOWED_USERS`, so you get "⛔ Not authorized"). On startup we therefore
|
|
9
|
+
* take an exclusive lock: if a still-alive instance holds it, we terminate that
|
|
10
|
+
* process (and its child tree on Windows) so the fresh process — with the
|
|
11
|
+
* current config — becomes the only consumer.
|
|
12
|
+
*
|
|
13
|
+
* The lock lives under the canonical home (`~/.kiro/tg/locks/<tokenHash>.lock`)
|
|
14
|
+
* and stores only a pid + start time + whether the holder is supervised. The
|
|
15
|
+
* token itself is never written to disk (only its hash names the file).
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from "node:child_process";
|
|
18
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { createLogger } from "../logger.js";
|
|
22
|
+
import { killPid } from "../sessions/process.js";
|
|
23
|
+
import { isPidAlive } from "../sessions/store.js";
|
|
24
|
+
|
|
25
|
+
const log = createLogger("lock");
|
|
26
|
+
|
|
27
|
+
interface LockData {
|
|
28
|
+
pid: number;
|
|
29
|
+
startedAt: number;
|
|
30
|
+
/** True when the holder runs under a supervisor (systemd/launchd/Task). */
|
|
31
|
+
supervised: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
export class InstanceLock {
|
|
37
|
+
private readonly file: string;
|
|
38
|
+
private held = false;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
token: string,
|
|
42
|
+
locksDir: string,
|
|
43
|
+
private readonly supervised: boolean,
|
|
44
|
+
) {
|
|
45
|
+
const hash = createHash("sha256").update(token).digest("hex").slice(0, 16);
|
|
46
|
+
this.file = join(locksDir, `${hash}.lock`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Become the sole instance for this token. Returns `false` (caller should
|
|
51
|
+
* exit) only when a *supervised* service instance is already running and this
|
|
52
|
+
* process is a plain manual start — we don't fight the background service
|
|
53
|
+
* (that would cause a restart/kill loop). Otherwise we take over: a live
|
|
54
|
+
* holder is terminated and the lock is rewritten with our pid.
|
|
55
|
+
*/
|
|
56
|
+
async acquire(): Promise<boolean> {
|
|
57
|
+
const existing = this.read();
|
|
58
|
+
if (existing && existing.pid !== process.pid && isPidAlive(existing.pid)) {
|
|
59
|
+
if (existing.supervised && !this.supervised) {
|
|
60
|
+
log.warn(`a supervised service instance is already running (pid ${existing.pid}); not starting a duplicate`);
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (looksLikeNode(existing.pid)) {
|
|
64
|
+
log.warn(`another bot instance is running (pid ${existing.pid}); terminating it to take over`);
|
|
65
|
+
killPid(existing.pid);
|
|
66
|
+
for (let i = 0; i < 20 && isPidAlive(existing.pid); i++) await sleep(150); // up to ~3s
|
|
67
|
+
if (isPidAlive(existing.pid)) log.warn(`previous instance ${existing.pid} still alive after kill; continuing anyway`);
|
|
68
|
+
} else {
|
|
69
|
+
// The locked pid was recycled to an unrelated process — don't kill it,
|
|
70
|
+
// just reclaim the stale lock.
|
|
71
|
+
log.warn(`lock pid ${existing.pid} is not a node process; reclaiming stale lock`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
this.write();
|
|
75
|
+
this.held = true;
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Release the lock if (and only if) we still own it. */
|
|
80
|
+
release(): void {
|
|
81
|
+
if (!this.held) return;
|
|
82
|
+
this.held = false;
|
|
83
|
+
try {
|
|
84
|
+
const cur = this.read();
|
|
85
|
+
if (cur?.pid === process.pid) rmSync(this.file, { force: true });
|
|
86
|
+
} catch {
|
|
87
|
+
/* best-effort */
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private write(): void {
|
|
92
|
+
const data: LockData = { pid: process.pid, startedAt: Date.now(), supervised: this.supervised };
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(join(this.file, ".."), { recursive: true });
|
|
95
|
+
writeFileSync(this.file, JSON.stringify(data), "utf-8");
|
|
96
|
+
} catch (e) {
|
|
97
|
+
log.warn(`could not write lock file ${this.file}: ${(e as Error).message}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private read(): LockData | undefined {
|
|
102
|
+
try {
|
|
103
|
+
const d = JSON.parse(readFileSync(this.file, "utf-8")) as Partial<LockData>;
|
|
104
|
+
if (typeof d.pid === "number" && d.pid > 0) {
|
|
105
|
+
return { pid: d.pid, startedAt: Number(d.startedAt) || 0, supervised: Boolean(d.supervised) };
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
/* no/invalid lock */
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Best-effort check that `pid` is a node process (our bot), to avoid killing an
|
|
116
|
+
* unrelated process that happened to reuse the pid. If the platform query can't
|
|
117
|
+
* run or be parsed, we assume it's ours (only this bot writes the lock) — better
|
|
118
|
+
* to clear a ghost than to leave one fighting over the token.
|
|
119
|
+
*/
|
|
120
|
+
function looksLikeNode(pid: number): boolean {
|
|
121
|
+
try {
|
|
122
|
+
if (process.platform === "win32") {
|
|
123
|
+
const out = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
|
|
124
|
+
encoding: "utf-8",
|
|
125
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
126
|
+
});
|
|
127
|
+
// No matching task prints an INFO line, not a CSV row — treat as "gone".
|
|
128
|
+
if (!/^\s*"/.test(out)) return false;
|
|
129
|
+
return /node\.exe|tsx/i.test(out);
|
|
130
|
+
}
|
|
131
|
+
const out = execFileSync("ps", ["-p", String(pid), "-o", "comm="], {
|
|
132
|
+
encoding: "utf-8",
|
|
133
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
134
|
+
});
|
|
135
|
+
return /node|tsx/i.test(out);
|
|
136
|
+
} catch {
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/bot/auth.ts
CHANGED
|
@@ -14,12 +14,23 @@ export function createAuthMiddleware(cfg: AppConfig) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
return async (ctx: Context, next: NextFunction): Promise<void> => {
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const from = ctx.from;
|
|
18
|
+
// Only a genuine USER action is subject to (and worth replying to) the auth
|
|
19
|
+
// gate. Ignore everything else silently — most importantly the bot's OWN
|
|
20
|
+
// updates: the status panel being pinned/unpinned emits a service message
|
|
21
|
+
// whose `from` is THIS bot (is_bot), and replying "⛔ Not authorized" to
|
|
22
|
+
// that (or to any service/no-`from` update) spammed the chat with false
|
|
23
|
+
// rejections. Real unauthorized users still get one clear reply below.
|
|
24
|
+
if (!from || from.is_bot) return;
|
|
25
|
+
const m = ctx.message ?? ctx.editedMessage;
|
|
26
|
+
if (m && (m.pinned_message || m.new_chat_members || m.left_chat_member)) return;
|
|
27
|
+
|
|
28
|
+
const userId = String(from.id);
|
|
29
|
+
if (allowAll || cfg.allowedUsers.has(userId)) {
|
|
19
30
|
await next();
|
|
20
31
|
return;
|
|
21
32
|
}
|
|
22
|
-
log.warn(`blocked unauthorized user ${userId
|
|
33
|
+
log.warn(`blocked unauthorized user ${userId}`);
|
|
23
34
|
if (ctx.chat) {
|
|
24
35
|
await ctx.reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID.");
|
|
25
36
|
}
|
package/src/bot/bot.ts
CHANGED
|
@@ -119,6 +119,11 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
|
|
|
119
119
|
const permissions = new PermissionService(bot.api, registry);
|
|
120
120
|
acp.permissionHandler = (p) => permissions.handle(p);
|
|
121
121
|
|
|
122
|
+
// The bot pins/unpins the status panel, and Telegram emits a "pinned a
|
|
123
|
+
// message" service message for each pin. Delete those so the chat stays clean
|
|
124
|
+
// — registered BEFORE auth so these bot-authored updates never reach the gate.
|
|
125
|
+
bot.on("message:pinned_message", (ctx) => void ctx.deleteMessage().catch(() => {}));
|
|
126
|
+
|
|
122
127
|
bot.use(createAuthMiddleware(cfg));
|
|
123
128
|
|
|
124
129
|
// Keep history clean: after handling, delete the user's command (/…) and
|
|
@@ -18,8 +18,8 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
|
|
|
18
18
|
"\u{1F44B} Welcome! I bridge Telegram to Kiro CLI over ACP.",
|
|
19
19
|
agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
|
|
20
20
|
"",
|
|
21
|
-
"Tap \u2630 Menu for everything.
|
|
22
|
-
"
|
|
21
|
+
"Tap \u2630 Menu for everything. A live status panel appears while I work",
|
|
22
|
+
"(\u2630 Menu \u2192 Status shows it anytime). Just send a message to start.",
|
|
23
23
|
].filter(Boolean);
|
|
24
24
|
await ctx.reply(lines.join("\n"), { reply_markup: compactKeyboard() });
|
|
25
25
|
await deps.statusPanel.refresh(ctx.chat.id);
|
|
@@ -29,34 +29,54 @@ export class StatusPanel {
|
|
|
29
29
|
// after switching between controlled sessions in different projects.
|
|
30
30
|
const project = rt.projectName || (rt.cwd ? basename(rt.cwd) : "(none)");
|
|
31
31
|
const session = rt.sessionId ? rt.sessionId.slice(0, 8) : "none";
|
|
32
|
-
const state = rt.isBusy ? "\u23F3 working" : "\u2705 idle";
|
|
33
|
-
const watch = rt.isWatching ? " \u{1F4E1} watching" : "";
|
|
34
32
|
const meta = rt.contextInfo();
|
|
35
|
-
const
|
|
33
|
+
const ctxPct = meta?.contextUsagePercentage;
|
|
36
34
|
const running = this.registry.controller(chatId).count();
|
|
37
|
-
const sessionLine = running > 1 ? `${session} \u{1F9ED} ${running} controlled` : session;
|
|
38
|
-
const lines = [
|
|
39
|
-
"\u{1F4CA} Kiro \u2014 Status",
|
|
40
|
-
`\u{1F4C1} Project: ${project}`,
|
|
41
|
-
`\u{1F916} Agent: ${s.agent || "default"}`,
|
|
42
|
-
`\u{1F9E0} Reasoning: ${reasoningLabel(s.reasoning)}`,
|
|
43
|
-
`\u{1F9E9} Model: ${s.model || "default"}`,
|
|
44
|
-
`\u{1F9F5} Session: ${sessionLine}`,
|
|
45
|
-
`\u{1F4CA} Context: ${ctx} used`,
|
|
46
|
-
`\u2699\uFE0F State: ${state} \u{1F4E5} Queue: ${rt.queueLength}${watch}`,
|
|
47
|
-
];
|
|
48
35
|
const subagents = this.registry.subagentSummaryForChat(chatId);
|
|
49
|
-
if (subagents) lines.push(`\u{1F465} Subagents: ${subagents}`);
|
|
50
36
|
const progress = rt.taskProgress;
|
|
51
|
-
|
|
37
|
+
|
|
38
|
+
const SEP = " | "; // pipe delimiter between inline fields
|
|
39
|
+
const lines: string[] = [];
|
|
40
|
+
|
|
41
|
+
// 1) Progress first — only while a turn is live (cleared when it ends), so
|
|
42
|
+
// the collapsed pin preview shows how far along the current task is.
|
|
43
|
+
if (progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(progress)}`);
|
|
44
|
+
|
|
45
|
+
// 2) Activity: state + only the counters that currently apply.
|
|
46
|
+
const activity: string[] = [rt.isBusy ? "\u23F3 Working" : "\u2705 Idle"];
|
|
47
|
+
if (rt.queueLength > 0) activity.push(`\u{1F4E5} ${rt.queueLength} queued`);
|
|
48
|
+
if (running > 1) activity.push(`\u{1F9ED} ${running} sessions`);
|
|
49
|
+
if (rt.isWatching) activity.push("\u{1F4E1} watching");
|
|
50
|
+
if (subagents) activity.push(`\u{1F465} ${subagents}`);
|
|
51
|
+
lines.push(activity.join(SEP));
|
|
52
|
+
|
|
53
|
+
// 3) Where: project | session | context usage.
|
|
54
|
+
const loc = [`\u{1F4C1} ${project}`, `\u{1F9F5} ${session}`];
|
|
55
|
+
if (ctxPct !== undefined) loc.push(`\u{1F4CA} ${ctxPct.toFixed(0)}% context`);
|
|
56
|
+
lines.push(loc.join(SEP));
|
|
57
|
+
|
|
58
|
+
// 4) How: agent | reasoning | model.
|
|
59
|
+
lines.push([`\u{1F916} ${s.agent || "default"}`, `\u{1F9E0} ${reasoningLabel(s.reasoning)}`, `\u{1F9E9} ${s.model || "default"}`].join(SEP));
|
|
60
|
+
|
|
52
61
|
return lines.join("\n");
|
|
53
62
|
}
|
|
54
63
|
|
|
55
64
|
/** Refresh (or create + pin) the status message for a chat. */
|
|
56
65
|
async refresh(chatId: number): Promise<void> {
|
|
57
|
-
const
|
|
66
|
+
const rt = this.registry.get(chatId);
|
|
58
67
|
const id = this.settings.get(chatId).statusMessageId;
|
|
59
68
|
|
|
69
|
+
// The pinned panel exists only while there's live work — a running turn or a
|
|
70
|
+
// queued follow-up about to run. When the session is idle there's nothing to
|
|
71
|
+
// show, so remove the panel to keep the chat clean. (The on-demand /status
|
|
72
|
+
// still renders full state when explicitly requested.)
|
|
73
|
+
const active = rt.isBusy || rt.queueLength > 0;
|
|
74
|
+
if (!active) {
|
|
75
|
+
if (id) await this.remove(chatId, id);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const text = this.render(chatId);
|
|
60
80
|
if (id) {
|
|
61
81
|
try {
|
|
62
82
|
await this.api.editMessageText(chatId, id, text);
|
|
@@ -69,6 +89,20 @@ export class StatusPanel {
|
|
|
69
89
|
await this.create(chatId, text);
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
/** Remove the pinned panel (unpin + delete) and forget its id. */
|
|
93
|
+
private async remove(chatId: number, id: number): Promise<void> {
|
|
94
|
+
this.settings.update(chatId, { statusMessageId: undefined });
|
|
95
|
+
try {
|
|
96
|
+
await this.api.deleteMessage(chatId, id); // deleting a pinned message also unpins it
|
|
97
|
+
} catch {
|
|
98
|
+
try {
|
|
99
|
+
await this.api.unpinChatMessage(chatId, id);
|
|
100
|
+
} catch {
|
|
101
|
+
/* best-effort */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
72
106
|
private async create(chatId: number, text: string): Promise<void> {
|
|
73
107
|
try {
|
|
74
108
|
const msg = await this.api.sendMessage(chatId, text, { disable_notification: true });
|
|
@@ -146,10 +146,13 @@ export class SessionRuntime {
|
|
|
146
146
|
return this.progress;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/** Record a new progress value and refresh the status panel / cards.
|
|
149
|
+
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
150
|
+
* is monotonic within a turn (it's reset to undefined when a new turn starts),
|
|
151
|
+
* so a streamer recreated mid-turn can't make it jump backwards. */
|
|
150
152
|
private setProgress(pct: number): void {
|
|
151
|
-
|
|
152
|
-
this.progress
|
|
153
|
+
const next = Math.max(this.progress ?? 0, pct);
|
|
154
|
+
if (next === this.progress) return;
|
|
155
|
+
this.progress = next;
|
|
153
156
|
this.changed();
|
|
154
157
|
}
|
|
155
158
|
|
|
@@ -173,7 +176,7 @@ export class SessionRuntime {
|
|
|
173
176
|
if (this.busy && !this.streamer) {
|
|
174
177
|
// Any transient follow-watch of this session is now superseded.
|
|
175
178
|
if (this.watchIsFollow) this.stopWatch();
|
|
176
|
-
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct));
|
|
179
|
+
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
|
|
177
180
|
this.typing.start();
|
|
178
181
|
}
|
|
179
182
|
} else {
|
|
@@ -459,14 +462,14 @@ export class SessionRuntime {
|
|
|
459
462
|
// session's previous in-flight turn (avoids duplicated output).
|
|
460
463
|
if (this.watchIsFollow) this.stopWatch();
|
|
461
464
|
const live = this.foreground;
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
this.turnStartedAt = startedAt;
|
|
462
467
|
this.streamer = live
|
|
463
|
-
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct))
|
|
468
|
+
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
|
|
464
469
|
: undefined;
|
|
465
470
|
if (live) this.typing.start();
|
|
466
471
|
this.activity(true);
|
|
467
472
|
this.changed();
|
|
468
|
-
const startedAt = Date.now();
|
|
469
|
-
this.turnStartedAt = startedAt;
|
|
470
473
|
this.imageScanText = "";
|
|
471
474
|
this.sentImagesThisTurn = new Set();
|
|
472
475
|
|
|
@@ -482,6 +485,9 @@ export class SessionRuntime {
|
|
|
482
485
|
const recovered = await this.maybeAutoFork(input, outcome);
|
|
483
486
|
const final = recovered ?? outcome;
|
|
484
487
|
const streamedOutput = this.streamer?.hasOutput ?? false;
|
|
488
|
+
// On a successful, non-cancelled turn, top the fallback bar up to 100 (a
|
|
489
|
+
// no-op when the agent reported its own progress — its value is kept).
|
|
490
|
+
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
485
491
|
if (this.streamer) await this.streamer.finalize();
|
|
486
492
|
if (this.foreground) await this.sendTurnImages();
|
|
487
493
|
// Always build the completion (records `lastCompletion` so switching back
|
|
@@ -517,6 +523,10 @@ export class SessionRuntime {
|
|
|
517
523
|
this.activity(false);
|
|
518
524
|
// The in-flight turn we may have been following live is over.
|
|
519
525
|
if (this.watchIsFollow) this.stopWatch();
|
|
526
|
+
// Turn ended (done / stopped / error): drop the live task-progress value so
|
|
527
|
+
// the bar is removed from the status panel, session cards and switch
|
|
528
|
+
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
529
|
+
this.progress = undefined;
|
|
520
530
|
this.changed();
|
|
521
531
|
}
|
|
522
532
|
|
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
|
|
13
|
+
import { ENV_PATH, INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
|
|
14
14
|
import { buildLaunchSpec, getController } from "./service/index.js";
|
|
15
15
|
|
|
16
16
|
const HELP = `Kiro Telegram Bot — CLI
|
|
@@ -18,7 +18,8 @@ const HELP = `Kiro Telegram Bot — CLI
|
|
|
18
18
|
Usage: kiro-tg <command>
|
|
19
19
|
|
|
20
20
|
run Run in the foreground
|
|
21
|
-
setup
|
|
21
|
+
setup [--path] Create/update .env (default ~/.kiro/tg/.env, loaded from
|
|
22
|
+
any folder); --path just prints the resolved .env location
|
|
22
23
|
install Install + start a background service (autostart on boot)
|
|
23
24
|
uninstall Stop + remove the background service
|
|
24
25
|
start Start the service
|
|
@@ -98,9 +99,9 @@ async function main(): Promise<void> {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function preflight(): void {
|
|
101
|
-
const envPath =
|
|
102
|
+
const envPath = ENV_PATH;
|
|
102
103
|
if (!existsSync(envPath)) {
|
|
103
|
-
console.warn(
|
|
104
|
+
console.warn(`⚠ No .env found at ${envPath}. Run \`kiro-tg setup\` and set TELEGRAM_BOT_TOKEN first.`);
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
const env = readFileSync(envPath, "utf-8");
|
package/src/config.ts
CHANGED
|
@@ -7,34 +7,44 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
|
|
10
|
-
loadDotenv();
|
|
11
|
-
|
|
12
10
|
/** Absolute path to the installed bot code (one level above src/). For a global
|
|
13
11
|
* npm install this lives inside node_modules — code lives here, never user data. */
|
|
14
12
|
export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
13
|
|
|
14
|
+
/** Canonical, path-independent home for this bot's `.env`, `logs/`, `data/` and
|
|
15
|
+
* the single-instance locks: `~/.kiro/tg`. Used whenever the bot is started
|
|
16
|
+
* without an explicit instance dir and there's no `.env` in the current folder,
|
|
17
|
+
* so the SAME configuration is found no matter which directory you launch from. */
|
|
18
|
+
export const CANONICAL_DIR = join(homedir(), ".kiro", "tg");
|
|
19
|
+
|
|
16
20
|
/**
|
|
17
|
-
* Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
|
|
21
|
+
* Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
|
|
22
|
+
* (first match wins):
|
|
18
23
|
* 1. `--instance <dir>` argv — set by the installed background service,
|
|
19
|
-
* 2. `
|
|
20
|
-
* 3. the
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* 2. `KIRO_TG_DIR` env — an explicit override,
|
|
25
|
+
* 3. `KIRO_TG_CWD` env — the legacy launcher variable,
|
|
26
|
+
* 4. the current folder, IF it already contains a `.env` (an explicit
|
|
27
|
+
* per-folder bot — keeps cloned/zip checkouts working in place),
|
|
28
|
+
* 5. the canonical `~/.kiro/tg` home — the path-independent default, so a
|
|
29
|
+
* `.env` created once is loaded no matter where the bot is started from.
|
|
24
30
|
*/
|
|
25
31
|
export const INSTANCE_DIR = resolveInstanceDir();
|
|
26
32
|
|
|
33
|
+
/** Absolute path to the `.env` this instance loads (and that `setup` writes). */
|
|
34
|
+
export const ENV_PATH = join(INSTANCE_DIR, ".env");
|
|
35
|
+
|
|
27
36
|
function resolveInstanceDir(): string {
|
|
28
37
|
const flag = process.argv.indexOf("--instance");
|
|
29
38
|
if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]!);
|
|
30
|
-
const
|
|
31
|
-
if (
|
|
32
|
-
return process.cwd();
|
|
39
|
+
const envDir = process.env.KIRO_TG_DIR?.trim() || process.env.KIRO_TG_CWD?.trim();
|
|
40
|
+
if (envDir) return resolve(expandHome(envDir));
|
|
41
|
+
if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
|
|
42
|
+
return CANONICAL_DIR;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
loadDotenv({ path:
|
|
45
|
+
// Load .env from the resolved instance directory. dotenv does NOT override
|
|
46
|
+
// variables already present in the environment (the launcher/service env wins).
|
|
47
|
+
loadDotenv({ path: ENV_PATH });
|
|
38
48
|
|
|
39
49
|
function expandHome(p: string): string {
|
|
40
50
|
if (p === "~") return homedir();
|
|
@@ -114,6 +124,9 @@ export interface AppConfig {
|
|
|
114
124
|
showSubagents: boolean;
|
|
115
125
|
/** Ask the agent to emit a `{progress: N%}` marker and render it as a bar. */
|
|
116
126
|
showProgress: boolean;
|
|
127
|
+
/** When the agent emits no `{progress}` marker, show a bot-computed fallback
|
|
128
|
+
* bar derived from real activity (tool calls, streamed output, elapsed). */
|
|
129
|
+
progressFallback: boolean;
|
|
117
130
|
/** Deliver a turn's "Done" summary to the chat even when that session is in
|
|
118
131
|
* the background (you've switched to another session). */
|
|
119
132
|
notifyOtherSessions: boolean;
|
|
@@ -121,6 +134,10 @@ export interface AppConfig {
|
|
|
121
134
|
autoUpdate: boolean;
|
|
122
135
|
/** How often to check npm for a newer version (ms). */
|
|
123
136
|
updateCheckMs: number;
|
|
137
|
+
/** Enforce a single running instance per bot token: on startup, a still-alive
|
|
138
|
+
* ghost/duplicate holding the lock is terminated so the fresh process (with
|
|
139
|
+
* the current `.env`) is the only Telegram getUpdates consumer. */
|
|
140
|
+
singleInstance: boolean;
|
|
124
141
|
}
|
|
125
142
|
|
|
126
143
|
export function loadConfig(): AppConfig {
|
|
@@ -185,9 +202,11 @@ export function loadConfig(): AppConfig {
|
|
|
185
202
|
mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6),
|
|
186
203
|
showSubagents: bool(process.env.SHOW_SUBAGENTS, true),
|
|
187
204
|
showProgress: bool(process.env.SHOW_PROGRESS, true),
|
|
205
|
+
progressFallback: bool(process.env.PROGRESS_FALLBACK, true),
|
|
188
206
|
notifyOtherSessions: bool(process.env.NOTIFY_OTHER_SESSIONS, true),
|
|
189
207
|
autoUpdate: bool(process.env.AUTO_UPDATE, true),
|
|
190
208
|
updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),
|
|
209
|
+
singleInstance: bool(process.env.KIRO_TG_SINGLE_INSTANCE, true),
|
|
191
210
|
};
|
|
192
211
|
|
|
193
212
|
return cfg;
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { AcpClient } from "./acp/client.js";
|
|
7
7
|
import { createBot } from "./bot/bot.js";
|
|
8
|
-
import { loadConfig } from "./config.js";
|
|
8
|
+
import { CANONICAL_DIR, loadConfig } from "./config.js";
|
|
9
|
+
import { InstanceLock } from "./app/instance-lock.js";
|
|
10
|
+
import { join } from "node:path";
|
|
9
11
|
import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
|
|
10
12
|
|
|
11
13
|
async function main(): Promise<void> {
|
|
@@ -17,6 +19,17 @@ async function main(): Promise<void> {
|
|
|
17
19
|
enableFileLogging(cfg.logFile);
|
|
18
20
|
const log = createLogger("main");
|
|
19
21
|
|
|
22
|
+
// Single-instance guard: kill any ghost/duplicate already polling this token
|
|
23
|
+
// (the usual cause of a stale "Not authorized" — an old process with an
|
|
24
|
+
// outdated .env). A plain manual start yields to a running background service.
|
|
25
|
+
const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.KIRO_TG_SUPERVISED === "1");
|
|
26
|
+
if (cfg.singleInstance && !(await lock.acquire())) {
|
|
27
|
+
process.stdout.write(
|
|
28
|
+
"\u26D4 Another Kiro Telegram Bot is already running for this token (a background service). Use `kiro-tg restart`, or `kiro-tg stop` first.\n",
|
|
29
|
+
);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
20
33
|
log.info("starting Kiro Telegram Bot");
|
|
21
34
|
log.info(`workspace: ${cfg.workspace}`);
|
|
22
35
|
log.info(`kiro-cli: ${cfg.kiroCliPath}`);
|
|
@@ -46,6 +59,7 @@ async function main(): Promise<void> {
|
|
|
46
59
|
registry.disposeAll();
|
|
47
60
|
void bot.stop().catch(() => {});
|
|
48
61
|
acp.stop();
|
|
62
|
+
lock.release();
|
|
49
63
|
setTimeout(() => process.exit(code), 500);
|
|
50
64
|
};
|
|
51
65
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bot-side FALLBACK task-progress estimate.
|
|
3
|
+
*
|
|
4
|
+
* The primary progress signal is the `{progress: N%}` marker the agent is asked
|
|
5
|
+
* to emit (see PROGRESS_DIRECTIVE). But that marker is only an *instruction* the
|
|
6
|
+
* model can ignore — weaker/free models and long, tool-heavy turns frequently
|
|
7
|
+
* never emit one, leaving the bar empty for the whole turn. This module gives
|
|
8
|
+
* the bot a way to show a live, advancing bar anyway, derived ONLY from real,
|
|
9
|
+
* observable work signals (never random):
|
|
10
|
+
*
|
|
11
|
+
* • completed tool calls — each is concrete progress, weighted most
|
|
12
|
+
* • streamed prose chars — the agent explaining / answering
|
|
13
|
+
* • streamed thinking chars — reasoning volume (weighted least)
|
|
14
|
+
* • elapsed time — a small, slow contribution so a quiet turn still creeps
|
|
15
|
+
*
|
|
16
|
+
* The estimate is monotonic by construction (every input only grows during a
|
|
17
|
+
* turn) and asymptotically capped well below 100 while running, so the bar never
|
|
18
|
+
* claims "done" on its own — the caller pushes 100 only when the turn actually
|
|
19
|
+
* completes. The agent's own marker, when present, always takes precedence.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Observable, monotonically-increasing signals collected during one turn. */
|
|
23
|
+
export interface ActivitySignals {
|
|
24
|
+
/** Number of tool calls / subagent transitions shown this turn. */
|
|
25
|
+
toolCalls: number;
|
|
26
|
+
/** Characters of agent prose streamed this turn. */
|
|
27
|
+
outputChars: number;
|
|
28
|
+
/** Characters of agent thinking streamed this turn. */
|
|
29
|
+
thoughtChars: number;
|
|
30
|
+
/** Milliseconds since the turn started. */
|
|
31
|
+
elapsedMs: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Hard ceiling for the fallback while a turn is still running. The agent (or
|
|
35
|
+
* turn completion) is the only thing allowed to take the bar to 100. */
|
|
36
|
+
export const FALLBACK_RUNNING_CAP = 90;
|
|
37
|
+
|
|
38
|
+
/** Minimum shown once *any* work signal is present (so the bar never sits at 0
|
|
39
|
+
* while the agent is clearly busy). */
|
|
40
|
+
const FALLBACK_FLOOR = 5;
|
|
41
|
+
|
|
42
|
+
/** Controls how quickly the asymptotic curve approaches the cap. Larger = slower. */
|
|
43
|
+
const CURVE_K = 6;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Map real work signals to a 0–FALLBACK_RUNNING_CAP estimate via a saturating
|
|
47
|
+
* curve `cap * (1 - e^(-units/K))`. Tool calls dominate because each is a
|
|
48
|
+
* discrete, completed step; text volume and elapsed time add gentle, diminishing
|
|
49
|
+
* contributions so a turn that's only thinking still advances slowly.
|
|
50
|
+
*/
|
|
51
|
+
export function estimateProgress(s: ActivitySignals): number {
|
|
52
|
+
const units =
|
|
53
|
+
Math.max(0, s.toolCalls) * 1.0 +
|
|
54
|
+
Math.max(0, s.outputChars) / 400 +
|
|
55
|
+
Math.max(0, s.thoughtChars) / 1500 +
|
|
56
|
+
Math.max(0, s.elapsedMs) / 30_000;
|
|
57
|
+
|
|
58
|
+
if (units <= 0) return 0;
|
|
59
|
+
|
|
60
|
+
const raw = FALLBACK_RUNNING_CAP * (1 - Math.exp(-units / CURVE_K));
|
|
61
|
+
const clamped = Math.min(FALLBACK_RUNNING_CAP, Math.max(FALLBACK_FLOOR, raw));
|
|
62
|
+
return Math.round(clamped);
|
|
63
|
+
}
|
package/src/stream/streamer.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Api } from "grammy";
|
|
|
14
14
|
import { chunkMarkdown } from "../render/chunk.js";
|
|
15
15
|
import { toTelegramMarkdown } from "../render/markdown.js";
|
|
16
16
|
import { extractProgress, progressBar } from "../render/progress.js";
|
|
17
|
+
import { estimateProgress } from "../render/progress-estimate.js";
|
|
17
18
|
import { safeEdit, safeSend } from "../bot/telegram-io.js";
|
|
18
19
|
|
|
19
20
|
const SOFT_LIMIT = 3500;
|
|
@@ -36,6 +37,13 @@ export class ResponseStreamer {
|
|
|
36
37
|
/** Latest task-progress % parsed from the agent's `{progress: N%}` markers
|
|
37
38
|
* (sticky across flushes; rendered as a bar on the live message). */
|
|
38
39
|
private progress: number | undefined;
|
|
40
|
+
/** True once the agent emitted a real `{progress}` marker — from then on its
|
|
41
|
+
* values are authoritative and the bot fallback stops contributing. */
|
|
42
|
+
private agentReported = false;
|
|
43
|
+
/** Real work signals for the fallback estimate (monotonic within a turn). */
|
|
44
|
+
private toolCalls = 0;
|
|
45
|
+
private outChars = 0;
|
|
46
|
+
private thoughtChars = 0;
|
|
39
47
|
|
|
40
48
|
constructor(
|
|
41
49
|
private readonly api: Api,
|
|
@@ -44,6 +52,10 @@ export class ResponseStreamer {
|
|
|
44
52
|
private replyTo?: number,
|
|
45
53
|
private footer?: string,
|
|
46
54
|
private readonly onProgress?: (pct: number) => void,
|
|
55
|
+
/** Show a bot-computed bar when the agent emits no marker. */
|
|
56
|
+
private readonly fallbackEnabled = false,
|
|
57
|
+
/** Turn start time, used by the fallback's elapsed-time signal. */
|
|
58
|
+
private readonly turnStartedAt = Date.now(),
|
|
47
59
|
) {}
|
|
48
60
|
|
|
49
61
|
/** Replace the hashtag footer (used after a logical fork swaps the session id
|
|
@@ -61,17 +73,45 @@ export class ResponseStreamer {
|
|
|
61
73
|
* value (sticky across flushes) and notifying the owner when it changes. */
|
|
62
74
|
private captureProgress(text: string): string {
|
|
63
75
|
const { value, cleaned } = extractProgress(text);
|
|
64
|
-
if (value !== undefined
|
|
65
|
-
this.progress = value;
|
|
66
|
-
try {
|
|
67
|
-
this.onProgress?.(value);
|
|
68
|
-
} catch {
|
|
69
|
-
/* non-fatal */
|
|
70
|
-
}
|
|
71
|
-
}
|
|
76
|
+
if (value !== undefined) this.setProgressValue(value, true);
|
|
72
77
|
return cleaned;
|
|
73
78
|
}
|
|
74
79
|
|
|
80
|
+
/** Record a progress value, enforcing global monotonicity (never decreases)
|
|
81
|
+
* and notifying the owner on change. Agent markers are authoritative: once
|
|
82
|
+
* one arrives, the bot fallback stops contributing. */
|
|
83
|
+
private setProgressValue(pct: number, fromAgent: boolean): void {
|
|
84
|
+
if (fromAgent) this.agentReported = true;
|
|
85
|
+
const next = Math.max(this.progress ?? 0, Math.round(pct));
|
|
86
|
+
if (next === this.progress) return;
|
|
87
|
+
this.progress = next;
|
|
88
|
+
try {
|
|
89
|
+
this.onProgress?.(next);
|
|
90
|
+
} catch {
|
|
91
|
+
/* non-fatal */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Advance the fallback estimate from real activity signals, but only while
|
|
96
|
+
* the agent itself hasn't reported a value. No-op when fallback is off. */
|
|
97
|
+
private applyFallback(): void {
|
|
98
|
+
if (!this.fallbackEnabled || this.agentReported) return;
|
|
99
|
+
const est = estimateProgress({
|
|
100
|
+
toolCalls: this.toolCalls,
|
|
101
|
+
outputChars: this.outChars,
|
|
102
|
+
thoughtChars: this.thoughtChars,
|
|
103
|
+
elapsedMs: Date.now() - this.turnStartedAt,
|
|
104
|
+
});
|
|
105
|
+
if (est > 0) this.setProgressValue(est, false);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Called when the turn finishes successfully: if the agent never reported
|
|
109
|
+
* its own progress, fill the fallback bar to 100. No-op otherwise. */
|
|
110
|
+
completeFallback(): void {
|
|
111
|
+
if (!this.fallbackEnabled || this.agentReported) return;
|
|
112
|
+
this.setProgressValue(100, false);
|
|
113
|
+
}
|
|
114
|
+
|
|
75
115
|
/** reply_parameters threading EVERY message of the turn to the user's prompt,
|
|
76
116
|
* so the whole response (all bubbles, tool calls and continuations) stays in
|
|
77
117
|
* one thread — not just the first message. */
|
|
@@ -82,18 +122,21 @@ export class ResponseStreamer {
|
|
|
82
122
|
|
|
83
123
|
appendOutput(text: string): void {
|
|
84
124
|
if (!text) return;
|
|
125
|
+
this.outChars += text.length;
|
|
85
126
|
this.merge("out", text);
|
|
86
127
|
this.schedule();
|
|
87
128
|
}
|
|
88
129
|
|
|
89
130
|
appendThought(text: string): void {
|
|
90
131
|
if (!text) return;
|
|
132
|
+
this.thoughtChars += text.length;
|
|
91
133
|
this.merge("think", text);
|
|
92
134
|
this.schedule();
|
|
93
135
|
}
|
|
94
136
|
|
|
95
137
|
addTool(rawMarkdown: string): void {
|
|
96
138
|
if (!rawMarkdown) return;
|
|
139
|
+
this.toolCalls += 1;
|
|
97
140
|
this.segs.push({ kind: "tool", text: rawMarkdown });
|
|
98
141
|
this.schedule();
|
|
99
142
|
}
|
|
@@ -138,11 +181,13 @@ export class ResponseStreamer {
|
|
|
138
181
|
try {
|
|
139
182
|
await this.sealOverflow();
|
|
140
183
|
const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
|
|
141
|
-
|
|
184
|
+
this.applyFallback();
|
|
185
|
+
// Never send an empty / progress-only bubble. The bar is appended only to
|
|
186
|
+
// real streamed content; the live status panel shows the standalone bar.
|
|
187
|
+
if (!base.trim()) return;
|
|
142
188
|
// The live (still-streaming) bubble carries the hashtag footer AND a fresh
|
|
143
189
|
// progress bar at the bottom (sealed bubbles below get neither bar).
|
|
144
|
-
const parts: string[] = [];
|
|
145
|
-
if (base.trim()) parts.push(base);
|
|
190
|
+
const parts: string[] = [base];
|
|
146
191
|
if (this.progress !== undefined) parts.push(progressBar(this.progress));
|
|
147
192
|
const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
|
|
148
193
|
const rendered = toTelegramMarkdown(src);
|