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 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 always shows your current **project, agent, reasoning effort, model, session and queue**. |
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
- Everything operates on the **current folder** (its `.env`, `logs/`, `data/`), so
85
- keep one folder per bot:
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
- mkdir my-bot && cd my-bot
89
- kiro-tg setup # auto-detects kiro-cli, writes ./.env
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
- Startup options: `kiro-tg setup | run | install | status | logs [n] | stop |
96
- restart | uninstall`. Or try it without installing: `npx kiro-telegram-bot
97
- setup`. See **[docs/INSTALL.md](./docs/INSTALL.md)** for the full guide.
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
- A **pinned status panel** at the top of the chat always shows your current
215
- **project, agent, reasoning effort, model, session id, context %, task progress,
216
- activity and queue** (and how many sessions the chat controls), updating live.
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.1",
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 .env from .env.example, auto-detects the kiro-cli binary
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, dirname } from "node:path";
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
- const [, , tokenArg, userArg] = process.argv;
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
- const idle = Date.now() - (this.lastActivity.get(sessionId) ?? start);
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 userId = ctx.from?.id ? String(ctx.from.id) : undefined;
18
- if (allowAll || (userId && cfg.allowedUsers.has(userId))) {
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 ?? "unknown"}`);
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. The pinned panel above always shows your",
22
- "project, agent, reasoning and model. Just send a message to start.",
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 ctx = meta?.contextUsagePercentage !== undefined ? `${meta.contextUsagePercentage.toFixed(0)}%` : "\u2014";
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
- if (progress !== undefined) lines.push(`\u{1F4C8} Progress: ${progressBar(progress)}`);
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 text = this.render(chatId);
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
- if (this.progress === pct) return;
152
- this.progress = pct;
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 Create/update .env in this folder (token + auto-detect)
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 = join(INSTANCE_DIR, ".env");
102
+ const envPath = ENV_PATH;
102
103
  if (!existsSync(envPath)) {
103
- console.warn("⚠ No .env found here. Run `kiro-tg setup` and set TELEGRAM_BOT_TOKEN first.");
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. `KIRO_TG_CWD` env — set by the `kiro-tg` launcher to the user's cwd,
20
- * 3. the current working directory.
21
- * For a cloned/zip checkout run in place this equals PROJECT_ROOT (so behaviour
22
- * is unchanged), while a global `npm i -g` install keeps user data in the
23
- * user's folder rather than inside node_modules.
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 env = process.env.KIRO_TG_CWD?.trim();
31
- if (env) return resolve(expandHome(env));
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
- // Re-load .env from the instance directory (the first call above also primed
36
- // process.env from cwd, which is the same place for an in-place checkout).
37
- loadDotenv({ path: join(INSTANCE_DIR, ".env") });
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
+ }
@@ -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 && value !== this.progress) {
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
- if (!base.trim() && this.progress === undefined) return;
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);