kiro-telegram-bot 1.6.0 → 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.
@@ -0,0 +1,462 @@
1
+ /**
2
+ * ReauthController — drives the whole `/reauth` flow on a SINGLE status message:
3
+ * pick login method → logout → device-flow login → agent restart. Instead of
4
+ * echoing every spinner frame the CLI emits, it shows one self-animated loader
5
+ * line plus inline controls (a method picker up front, Cancel while running,
6
+ * Retry / Restart agent on failure).
7
+ *
8
+ * Kiro CLI can authenticate several ways, so the user chooses first instead of
9
+ * always defaulting to one provider:
10
+ * • Builder ID (free) → `--license free`
11
+ * • Google / GitHub (social) → `--license free --social <provider>`
12
+ * • IAM Identity Center (pro) → `--license pro --identity-provider <url> --region <region>`
13
+ * Every method runs through `--use-device-flow` so approval happens on the
14
+ * user's own device (no browser redirect on the bot host).
15
+ *
16
+ * State is kept per chat so the button callbacks (which arrive on a separate
17
+ * update) can cancel the in-flight login or re-run the flow on the same message.
18
+ */
19
+ import { type Api, InlineKeyboard } from "grammy";
20
+ import type { AcpClient } from "../acp/client.js";
21
+ import { AuthService } from "../app/auth-service.js";
22
+ import type { AccountInfo } from "../app/usage.js";
23
+ import { createLogger } from "../logger.js";
24
+ import { parseDeviceFlow } from "../render/device-flow.js";
25
+
26
+ const log = createLogger("reauth");
27
+
28
+ /** A 7-segment bar we cycle ourselves — one line, throttled, "infinite" loader. */
29
+ const LOADER = ["▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰▰▱▱▱▱", "▰▰▰▰▱▱▱", "▰▰▰▰▰▱▱", "▰▰▰▰▰▰▱", "▰▰▰▰▰▰▰"];
30
+ const ANIM_MS = 2500;
31
+ const LOGIN_TIMEOUT_MS = 300_000;
32
+
33
+ /** Login methods exposed in the picker. */
34
+ export type LoginMethod = "builder" | "google" | "github" | "idc";
35
+
36
+ /** CLI flags for a chosen method (`--use-device-flow` is added by AuthService). */
37
+ function methodArgs(method: LoginMethod, idc?: { url: string; region: string }): string[] {
38
+ switch (method) {
39
+ case "builder":
40
+ return ["--license", "free"];
41
+ case "google":
42
+ return ["--license", "free", "--social", "google"];
43
+ case "github":
44
+ return ["--license", "free", "--social", "github"];
45
+ case "idc":
46
+ return ["--license", "pro", "--identity-provider", idc!.url, "--region", idc!.region];
47
+ }
48
+ }
49
+
50
+ const METHOD_LABEL: Record<LoginMethod, string> = {
51
+ builder: "Builder ID",
52
+ google: "Google",
53
+ github: "GitHub",
54
+ idc: "IAM Identity Center",
55
+ };
56
+
57
+ /** Pull a start URL + region out of the user's free-text IDC reply. */
58
+ function parseIdcInput(text: string): { url: string; region: string } | undefined {
59
+ const tokens = text.trim().split(/\s+/).filter(Boolean);
60
+ const url = tokens.find((t) => /^https?:\/\//i.test(t));
61
+ const region = tokens.find((t) => /^[a-z]{2}-[a-z]+-\d+$/i.test(t));
62
+ if (!url || !region) return undefined;
63
+ return { url, region };
64
+ }
65
+
66
+ type Phase =
67
+ | "choosing"
68
+ | "idc_input"
69
+ | "logout"
70
+ | "login"
71
+ | "restarting"
72
+ | "done"
73
+ | "failed_login"
74
+ | "failed_restart"
75
+ | "cancelled";
76
+ const ACTIVE: ReadonlySet<Phase> = new Set<Phase>(["logout", "login", "restarting"]);
77
+
78
+ /** Human label for the logged-in identity, for the success message. */
79
+ function accountLabel(a: AccountInfo | undefined): string | undefined {
80
+ if (!a) return undefined;
81
+ return a.email || a.startUrl || a.accountType;
82
+ }
83
+
84
+ interface ReauthSession {
85
+ chatId: number;
86
+ messageId: number;
87
+ phase: Phase;
88
+ extra: string[];
89
+ abort?: AbortController;
90
+ anim?: NodeJS.Timeout;
91
+ frame: number;
92
+ url?: string;
93
+ code?: string;
94
+ errorMsg?: string;
95
+ accountLabel?: string;
96
+ lastText?: string;
97
+ /** Chosen login method, for the success message and IDC follow-up. */
98
+ method?: LoginMethod;
99
+ /** IAM Identity Center start URL + region (only set for the `idc` method). */
100
+ idc?: { url: string; region: string };
101
+ }
102
+
103
+ export class ReauthController {
104
+ private readonly auth: AuthService;
105
+ private readonly sessions = new Map<number, ReauthSession>();
106
+
107
+ constructor(
108
+ private readonly api: Api,
109
+ private readonly acp: AcpClient,
110
+ kiroCliPath: string,
111
+ /** Resolves the current account (kiro-cli whoami) to confirm the identity. */
112
+ private readonly getAccount?: () => Promise<AccountInfo | undefined>,
113
+ ) {
114
+ this.auth = new AuthService(kiroCliPath);
115
+ }
116
+
117
+ /** True while a reauth flow is actively running for a chat. */
118
+ isBusy(chatId: number): boolean {
119
+ const s = this.sessions.get(chatId);
120
+ return !!s && ACTIVE.has(s.phase);
121
+ }
122
+
123
+ /** True while ANY chat is mid-reauth. Logout/login and the restart all touch
124
+ * the single shared agent and global credentials, so only one may run. */
125
+ private anyActive(): boolean {
126
+ for (const s of this.sessions.values()) if (ACTIVE.has(s.phase)) return true;
127
+ return false;
128
+ }
129
+
130
+ /** Show the login-method picker on a fresh (or reused) status message. */
131
+ async chooseMethod(chatId: number, existingMessageId?: number): Promise<void> {
132
+ if (this.isBusy(chatId)) return;
133
+ let messageId = existingMessageId;
134
+ if (messageId === undefined) {
135
+ const m = await this.api.sendMessage(chatId, "\u{1F510} Re-authenticate Kiro\u2026").catch(() => undefined);
136
+ if (!m) return;
137
+ messageId = m.message_id;
138
+ }
139
+ const s: ReauthSession = { chatId, messageId, phase: "choosing", extra: [], frame: 0 };
140
+ this.sessions.set(chatId, s);
141
+ await this.render(s);
142
+ }
143
+
144
+ /** Handle a method choice from the picker. IDC needs a URL + region first. */
145
+ async pickMethod(chatId: number, messageId: number, method: LoginMethod): Promise<void> {
146
+ const s = this.sessions.get(chatId);
147
+ if (!s || s.phase !== "choosing") return; // stale/expired picker
148
+ s.messageId = messageId;
149
+ s.method = method;
150
+ if (method === "idc") {
151
+ s.phase = "idc_input";
152
+ s.errorMsg = undefined;
153
+ s.lastText = undefined;
154
+ await this.render(s);
155
+ return;
156
+ }
157
+ await this.begin(chatId, methodArgs(method), messageId, method);
158
+ }
159
+
160
+ /** True while waiting for the user to type their IDC start URL + region. */
161
+ awaitingIdcInput(chatId: number): boolean {
162
+ return this.sessions.get(chatId)?.phase === "idc_input";
163
+ }
164
+
165
+ /** Consume the IDC start URL + region text and kick off the login. */
166
+ async submitIdcInput(chatId: number, text: string): Promise<void> {
167
+ const s = this.sessions.get(chatId);
168
+ if (!s || s.phase !== "idc_input") return;
169
+ const parsed = parseIdcInput(text);
170
+ if (!parsed) {
171
+ s.errorMsg = "Couldn't read a start URL and region. Example: https://my-org.awsapps.com/start us-east-1";
172
+ s.lastText = undefined;
173
+ await this.render(s);
174
+ return;
175
+ }
176
+ await this.begin(chatId, [], s.messageId, "idc", parsed);
177
+ }
178
+
179
+ /** Cancel an in-progress picker / IDC prompt (before any logout happened). */
180
+ async cancelChoice(chatId: number, messageId: number): Promise<void> {
181
+ const s = this.sessions.get(chatId);
182
+ if (s && (s.phase === "choosing" || s.phase === "idc_input")) this.sessions.delete(chatId);
183
+ await this.api.editMessageText(chatId, messageId, "\u{1F510} Re-authentication cancelled.").catch(() => {});
184
+ }
185
+
186
+ /** Start (or restart) the flow. Reuses `existingMessageId` for the Retry button. */
187
+ async begin(
188
+ chatId: number,
189
+ extra: string[],
190
+ existingMessageId?: number,
191
+ method?: LoginMethod,
192
+ idc?: { url: string; region: string },
193
+ ): Promise<void> {
194
+ if (this.isBusy(chatId)) return;
195
+ if (this.anyActive()) {
196
+ await this.api
197
+ .sendMessage(chatId, "\u{1F510} A re-authentication is already in progress in another chat — try again shortly.")
198
+ .catch(() => {});
199
+ return;
200
+ }
201
+ if (this.acp.hasInflightPrompt()) {
202
+ await this.api
203
+ .sendMessage(chatId, "\u23F3 Kiro is busy running a turn — try /reauth when idle (or /cancel first).")
204
+ .catch(() => {});
205
+ return;
206
+ }
207
+ let messageId = existingMessageId;
208
+ if (messageId === undefined) {
209
+ const m = await this.api.sendMessage(chatId, "\u{1F510} Re-authenticating Kiro\u2026").catch(() => undefined);
210
+ if (!m) return;
211
+ messageId = m.message_id;
212
+ }
213
+ const s: ReauthSession = { chatId, messageId, phase: "logout", extra, frame: 0, method, idc };
214
+ this.sessions.set(chatId, s);
215
+ void this.run(s);
216
+ }
217
+
218
+ /** Abort the in-flight login/logout for a chat. Returns false if idle. */
219
+ cancel(chatId: number): boolean {
220
+ const s = this.sessions.get(chatId);
221
+ if (!s || !ACTIVE.has(s.phase)) return false;
222
+ s.abort?.abort();
223
+ return true;
224
+ }
225
+
226
+ /** Re-run the whole flow on the existing status message (Retry button). */
227
+ async retry(chatId: number, messageId: number): Promise<void> {
228
+ const prev = this.sessions.get(chatId);
229
+ await this.begin(chatId, prev?.extra ?? [], messageId, prev?.method, prev?.idc);
230
+ }
231
+
232
+ /** Just restart the agent again (Restart-agent button after a restart failure). */
233
+ async restartAgent(chatId: number, messageId: number): Promise<void> {
234
+ if (this.isBusy(chatId) || this.anyActive()) return;
235
+ const s: ReauthSession = this.sessions.get(chatId) ?? { chatId, messageId, phase: "restarting", extra: [], frame: 0 };
236
+ s.messageId = messageId;
237
+ s.phase = "restarting";
238
+ s.errorMsg = undefined;
239
+ this.sessions.set(chatId, s);
240
+ this.startAnim(s);
241
+ await this.render(s);
242
+ try {
243
+ await this.acp.restart();
244
+ s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
245
+ s.phase = "done";
246
+ } catch (e) {
247
+ s.phase = "failed_restart";
248
+ s.errorMsg = (e as Error).message;
249
+ }
250
+ this.stopAnim(s);
251
+ await this.render(s);
252
+ }
253
+
254
+ // ── flow ───────────────────────────────────────────────────────────────────
255
+
256
+ private async run(s: ReauthSession): Promise<void> {
257
+ s.abort = new AbortController();
258
+ s.accountLabel = undefined;
259
+ // The ACP agent is shared; while it runs it keeps refreshing and rewriting
260
+ // the cached token, which would silently restore the OLD identity right
261
+ // after we log out. So we take it down FIRST and only bring it back once a
262
+ // fresh login has written new credentials.
263
+ let agentDown = false;
264
+ try {
265
+ s.phase = "logout";
266
+ this.startAnim(s);
267
+ await this.render(s);
268
+
269
+ await this.acp.stopAndWait(); // release the held session before logout
270
+ agentDown = true;
271
+ await this.auth.logout();
272
+ await this.auth.clearTokenCache(); // ensure login can't reuse a cached token
273
+ if (s.abort.signal.aborted) {
274
+ s.phase = "cancelled";
275
+ return;
276
+ }
277
+
278
+ s.phase = "login";
279
+ s.url = undefined;
280
+ s.code = undefined;
281
+ await this.render(s);
282
+ let raw = "";
283
+ const onOutput = (t: string): void => {
284
+ raw += t;
285
+ this.ingest(s, raw);
286
+ };
287
+ const result =
288
+ s.method === "idc" && s.idc
289
+ ? await this.auth.loginIdc({
290
+ startUrl: s.idc.url,
291
+ region: s.idc.region,
292
+ timeoutMs: LOGIN_TIMEOUT_MS,
293
+ signal: s.abort.signal,
294
+ onOutput,
295
+ })
296
+ : await this.auth.login({
297
+ extraArgs: s.extra,
298
+ timeoutMs: LOGIN_TIMEOUT_MS,
299
+ signal: s.abort.signal,
300
+ onOutput,
301
+ });
302
+ if (result.cancelled || s.abort.signal.aborted) {
303
+ s.phase = "cancelled";
304
+ return;
305
+ }
306
+ if (!result.ok) {
307
+ s.phase = "failed_login";
308
+ s.errorMsg = result.error ?? `Login did not complete (exit ${result.code ?? "?"}).`;
309
+ return;
310
+ }
311
+
312
+ s.phase = "restarting";
313
+ await this.render(s);
314
+ try {
315
+ await this.acp.restart(); // fresh agent picks up the new credentials
316
+ agentDown = false;
317
+ s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
318
+ s.phase = "done";
319
+ } catch (e) {
320
+ s.phase = "failed_restart";
321
+ s.errorMsg = (e as Error).message;
322
+ }
323
+ } catch (e) {
324
+ log.warn("reauth flow failed:", (e as Error).message);
325
+ s.phase = "failed_login";
326
+ s.errorMsg = (e as Error).message;
327
+ } finally {
328
+ s.abort = undefined;
329
+ this.stopAnim(s);
330
+ // If we took the agent down but never brought it back (cancel / login
331
+ // failure), restart it so the bot isn't left without a live agent. A
332
+ // failed_restart already tried — leave its button instead.
333
+ if (agentDown && (s.phase === "cancelled" || s.phase === "failed_login")) {
334
+ await this.acp.restart().catch((e) => log.warn("post-reauth agent restart failed:", (e as Error).message));
335
+ }
336
+ await this.render(s);
337
+ }
338
+ }
339
+
340
+ /** Pull stable URL/code out of the streaming output; re-render when they change. */
341
+ private ingest(s: ReauthSession, raw: string): void {
342
+ const p = parseDeviceFlow(raw);
343
+ let changed = false;
344
+ if (p.url && p.url !== s.url) {
345
+ s.url = p.url;
346
+ changed = true;
347
+ }
348
+ if (p.code && p.code !== s.code) {
349
+ s.code = p.code;
350
+ changed = true;
351
+ }
352
+ if (changed) void this.render(s);
353
+ }
354
+
355
+ private startAnim(s: ReauthSession): void {
356
+ if (s.anim) return;
357
+ s.anim = setInterval(() => {
358
+ s.frame++;
359
+ void this.render(s);
360
+ }, ANIM_MS);
361
+ }
362
+
363
+ private stopAnim(s: ReauthSession): void {
364
+ if (s.anim) {
365
+ clearInterval(s.anim);
366
+ s.anim = undefined;
367
+ }
368
+ }
369
+
370
+ // ── rendering ────────────────────────────────────────────────────────────────
371
+
372
+ private text(s: ReauthSession): string {
373
+ const loader = LOADER[s.frame % LOADER.length] ?? "";
374
+ switch (s.phase) {
375
+ case "choosing":
376
+ return "\u{1F510} Re-authenticate Kiro\nChoose how you want to log in:";
377
+ case "idc_input":
378
+ return (
379
+ "\u{1F3E2} IAM Identity Center (Pro)\n\n" +
380
+ "Send your start URL and Region in one message, separated by a space:\n" +
381
+ "https://my-org.awsapps.com/start us-east-1" +
382
+ (s.errorMsg ? `\n\n\u26A0\uFE0F ${s.errorMsg}` : "")
383
+ );
384
+ case "logout":
385
+ return `\u{1F510} Re-authenticating Kiro\u2026\n\u{1F6AA} Logging out\u2026 ${loader}`;
386
+ case "login": {
387
+ const provider = s.method ? ` \u00B7 ${METHOD_LABEL[s.method]}` : "";
388
+ const lines = [`\u{1F511} Kiro login (device flow)${provider}`, ""];
389
+ if (s.url) lines.push(`\u{1F517} Open this link to approve:\n${s.url}`, "");
390
+ if (s.code) lines.push(`\u{1F522} Verification code: ${s.code}`, "");
391
+ if (!s.url && !s.code) lines.push("Starting device-flow login\u2026", "");
392
+ else lines.push("Confirm it in the browser, then this updates automatically.", "");
393
+ lines.push(`${loader} Waiting for approval\u2026`);
394
+ return lines.join("\n");
395
+ }
396
+ case "restarting":
397
+ return `\u2705 Logged in.\n\u{1F504} Restarting the Kiro agent\u2026 ${loader}`;
398
+ case "done":
399
+ return (
400
+ `\u2705 Re-authenticated${s.accountLabel ? ` as ${s.accountLabel}` : ""} and agent restarted.\n` +
401
+ "Your session re-binds on the next message." +
402
+ (s.accountLabel ? "" : "\n(Tip: /usage shows the active account.)")
403
+ );
404
+ case "cancelled":
405
+ return "\u{1F6D1} Login cancelled \u2014 you're logged out. Tap Retry, or Change method to pick another.";
406
+ case "failed_login":
407
+ return (
408
+ `\u274C ${s.errorMsg ?? "Login failed."}\n` +
409
+ "Tap Retry to try the same method again, or Change method to pick another."
410
+ );
411
+ case "failed_restart":
412
+ return `\u26A0\uFE0F Logged in, but the agent restart failed: ${s.errorMsg ?? "unknown error"}.`;
413
+ default:
414
+ return "";
415
+ }
416
+ }
417
+
418
+ private keyboard(s: ReauthSession): InlineKeyboard | undefined {
419
+ switch (s.phase) {
420
+ case "choosing":
421
+ return new InlineKeyboard()
422
+ .text("\u{1F193} Builder ID (free)", "reauth:method:builder")
423
+ .row()
424
+ .text("\u{1F310} Google", "reauth:method:google")
425
+ .text("\u{1F431} GitHub", "reauth:method:github")
426
+ .row()
427
+ .text("\u{1F3E2} IAM Identity Center", "reauth:method:idc")
428
+ .row()
429
+ .text("\u274C Cancel", "reauth:choose-cancel");
430
+ case "idc_input":
431
+ return new InlineKeyboard()
432
+ .text("\u2B05 Back", "reauth:choose-back")
433
+ .text("\u274C Cancel", "reauth:choose-cancel");
434
+ case "logout":
435
+ case "login":
436
+ return new InlineKeyboard().text("\u274C Cancel", "reauth:cancel");
437
+ case "cancelled":
438
+ case "failed_login":
439
+ return new InlineKeyboard()
440
+ .text("\u{1F501} Retry", "reauth:retry")
441
+ .text("\u{1F504} Change method", "reauth:choose-back");
442
+ case "failed_restart":
443
+ return new InlineKeyboard()
444
+ .text("\u{1F504} Restart agent", "reauth:restart")
445
+ .text("\u{1F501} Retry login", "reauth:retry");
446
+ default:
447
+ return undefined;
448
+ }
449
+ }
450
+
451
+ private async render(s: ReauthSession): Promise<void> {
452
+ const text = this.text(s);
453
+ if (text === s.lastText) return; // unchanged → skip ("message is not modified")
454
+ s.lastText = text;
455
+ await this.api
456
+ .editMessageText(s.chatId, s.messageId, text, {
457
+ reply_markup: this.keyboard(s),
458
+ link_preview_options: { is_disabled: true },
459
+ })
460
+ .catch(() => {});
461
+ }
462
+ }
@@ -5,7 +5,7 @@
5
5
  * to the settings store so it survives restarts.
6
6
  */
7
7
  import { basename } from "node:path";
8
- import type { Api } from "grammy";
8
+ import { type Api, InlineKeyboard } from "grammy";
9
9
  import { type AcpClient, isContextExhaustedError, isTransientAcpError } from "../acp/client.js";
10
10
  import type { ContentBlock, PromptResult, SessionUpdate } from "../acp/types.js";
11
11
  import type { AppConfig } from "../config.js";
@@ -15,6 +15,7 @@ import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types
15
15
  import { createLogger } from "../logger.js";
16
16
  import { buildTranscript } from "../sessions/history.js";
17
17
  import { sessionHashtags } from "../render/hashtags.js";
18
+ import { PROGRESS_DIRECTIVE } from "../render/progress.js";
18
19
  import { buildPriming, recentTranscript } from "./session-fork.js";
19
20
  import { TailWatcher } from "../sessions/tail.js";
20
21
  import type { HistoryEntry } from "../sessions/types.js";
@@ -62,6 +63,9 @@ export class SessionRuntime {
62
63
  /** The full Done/summary of the most recent finished turn, replayed when you
63
64
  * switch (back) into this session so you see how it ended. */
64
65
  private lastCompletion: string | undefined;
66
+ /** Latest task-completion % parsed from the agent's `{progress: N%}` markers,
67
+ * shown as a bar in the status panel and session cards. Reset each turn. */
68
+ private progress: number | undefined;
65
69
  /** Subagent sessionId -> last status key shown this turn (dedupe). */
66
70
  private subagentShown = new Map<string, string>();
67
71
  private turnStartedAt = 0;
@@ -137,6 +141,21 @@ export class SessionRuntime {
137
141
  return this.lastCompletion;
138
142
  }
139
143
 
144
+ /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
145
+ get taskProgress(): number | undefined {
146
+ return this.progress;
147
+ }
148
+
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. */
152
+ private setProgress(pct: number): void {
153
+ const next = Math.max(this.progress ?? 0, pct);
154
+ if (next === this.progress) return;
155
+ this.progress = next;
156
+ this.changed();
157
+ }
158
+
140
159
  /** Searchable hashtag footer for this session (project · session · model ·
141
160
  * reasoning) — appended to every AI-output surface for this session. */
142
161
  get tags(): string {
@@ -157,7 +176,7 @@ export class SessionRuntime {
157
176
  if (this.busy && !this.streamer) {
158
177
  // Any transient follow-watch of this session is now superseded.
159
178
  if (this.watchIsFollow) this.stopWatch();
160
- this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags());
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);
161
180
  this.typing.start();
162
181
  }
163
182
  } else {
@@ -438,24 +457,26 @@ export class SessionRuntime {
438
457
  this.shownToolIds = new Set();
439
458
  this.fileOps = new Map();
440
459
  this.subagentShown = new Map();
460
+ this.progress = undefined; // a new turn = a new task; clear the old bar
441
461
  // A new streamed turn supersedes any transient "follow" watch of this same
442
462
  // session's previous in-flight turn (avoids duplicated output).
443
463
  if (this.watchIsFollow) this.stopWatch();
444
464
  const live = this.foreground;
465
+ const startedAt = Date.now();
466
+ this.turnStartedAt = startedAt;
445
467
  this.streamer = live
446
- ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags())
468
+ ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
447
469
  : undefined;
448
470
  if (live) this.typing.start();
449
471
  this.activity(true);
450
472
  this.changed();
451
- const startedAt = Date.now();
452
- this.turnStartedAt = startedAt;
453
473
  this.imageScanText = "";
454
474
  this.sentImagesThisTurn = new Set();
455
475
 
456
476
  const content = buildContentBlocks(input, {
457
477
  reasoning: reasoningDirective(this.reasoning),
458
478
  priming: this.primingContext,
479
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
459
480
  });
460
481
  this.primingContext = undefined;
461
482
 
@@ -464,19 +485,27 @@ export class SessionRuntime {
464
485
  const recovered = await this.maybeAutoFork(input, outcome);
465
486
  const final = recovered ?? outcome;
466
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();
467
491
  if (this.streamer) await this.streamer.finalize();
468
492
  if (this.foreground) await this.sendTurnImages();
469
493
  // Always build the completion (records `lastCompletion` so switching back
470
494
  // to this session can replay its Done + summary). Only PING the chat for
471
495
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
472
496
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
497
+ // A background session about to run a queued follow-up shouldn't ping its
498
+ // interim "Done" — only the final, queue-empty turn announces completion.
499
+ const hasQueued = this.queue.length > 0;
500
+ const switchKb = this.switchKeyboard();
473
501
  if (final.result || this.cancelled) {
474
502
  const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
475
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
503
+ const pingDone = canPing && (this.foreground || !hasQueued);
504
+ if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
476
505
  } else if (final.error) {
477
506
  const transient = isTransientAcpError(final.error);
478
507
  const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
479
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
508
+ if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
480
509
  }
481
510
  } catch (err) {
482
511
  // Unexpected failure outside the prompt path (e.g. while finalizing).
@@ -485,7 +514,7 @@ export class SessionRuntime {
485
514
  this.lastCompletion = msg;
486
515
  if (this.foreground || this.cfg.notifyOtherSessions) {
487
516
  const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
488
- await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo });
517
+ await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
489
518
  }
490
519
  } finally {
491
520
  this.typing.stop();
@@ -494,6 +523,10 @@ export class SessionRuntime {
494
523
  this.activity(false);
495
524
  // The in-flight turn we may have been following live is over.
496
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;
497
530
  this.changed();
498
531
  }
499
532
 
@@ -589,6 +622,7 @@ export class SessionRuntime {
589
622
  const forkContent = buildContentBlocks(input, {
590
623
  reasoning: reasoningDirective(this.reasoning),
591
624
  priming: transcript ? buildPriming(transcript) : undefined,
625
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
592
626
  });
593
627
  return this.runPromptWithRetries(forkContent);
594
628
  }
@@ -708,6 +742,14 @@ export class SessionRuntime {
708
742
  return `[${name}${id}]`;
709
743
  }
710
744
 
745
+ /** Inline keyboard offering to switch to this session, attached to background
746
+ * ("From other session") pings so you can jump straight in. Foreground turns
747
+ * are already in view, so they get no button. */
748
+ private switchKeyboard(): InlineKeyboard | undefined {
749
+ if (this.foreground || !this.sessionId) return undefined;
750
+ return new InlineKeyboard().text("\u{1F500} Switch to this session", `run:switch:${this.sessionId}`);
751
+ }
752
+
711
753
  /** Searchable Telegram hashtags so you can pull up every message of a session
712
754
  * or project by tapping the tag. */
713
755
  private hashtags(): string {
@@ -793,12 +835,16 @@ export class SessionRuntime {
793
835
  }
794
836
  }
795
837
 
796
- private async notify(text: string, opts?: { loud?: boolean; replyTo?: number }): Promise<void> {
838
+ private async notify(
839
+ text: string,
840
+ opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
841
+ ): Promise<void> {
797
842
  try {
798
843
  const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
799
844
  if (opts?.replyTo !== undefined) {
800
845
  extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
801
846
  }
847
+ if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
802
848
  await this.api.sendMessage(this.chatId, text, extra);
803
849
  } catch {
804
850
  /* non-fatal */
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");