kiro-telegram-bot 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@
6
6
  import { type Api, GrammyError } from "grammy";
7
7
  import { basename } from "node:path";
8
8
  import { reasoningLabel } from "../../app/reasoning.js";
9
+ import { progressBar } from "../../render/progress.js";
9
10
  import type { SettingsStore } from "../../app/settings-store.js";
10
11
  import { createLogger } from "../../logger.js";
11
12
  import type { RuntimeRegistry } from "../registry.js";
@@ -46,6 +47,8 @@ export class StatusPanel {
46
47
  ];
47
48
  const subagents = this.registry.subagentSummaryForChat(chatId);
48
49
  if (subagents) lines.push(`\u{1F465} Subagents: ${subagents}`);
50
+ const progress = rt.taskProgress;
51
+ if (progress !== undefined) lines.push(`\u{1F4C8} Progress: ${progressBar(progress)}`);
49
52
  return lines.join("\n");
50
53
  }
51
54
 
@@ -9,6 +9,8 @@ import type { PromptInput } from "../app/types.js";
9
9
  export interface ContentOptions {
10
10
  reasoning?: string;
11
11
  priming?: string;
12
+ /** Appended at the very bottom so the agent emits a `{progress: N%}` marker. */
13
+ progress?: string;
12
14
  }
13
15
 
14
16
  export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
@@ -28,6 +30,9 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
28
30
  if (opts.reasoning) {
29
31
  text = `(${opts.reasoning})\n\n${text}`;
30
32
  }
33
+ if (opts.progress) {
34
+ text = `${text}\n\n${opts.progress}`;
35
+ }
31
36
 
32
37
  blocks.push({ type: "text", text });
33
38
  return blocks;
@@ -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,18 @@ 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. */
150
+ private setProgress(pct: number): void {
151
+ if (this.progress === pct) return;
152
+ this.progress = pct;
153
+ this.changed();
154
+ }
155
+
140
156
  /** Searchable hashtag footer for this session (project · session · model ·
141
157
  * reasoning) — appended to every AI-output surface for this session. */
142
158
  get tags(): string {
@@ -157,7 +173,7 @@ export class SessionRuntime {
157
173
  if (this.busy && !this.streamer) {
158
174
  // Any transient follow-watch of this session is now superseded.
159
175
  if (this.watchIsFollow) this.stopWatch();
160
- this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags());
176
+ this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct));
161
177
  this.typing.start();
162
178
  }
163
179
  } else {
@@ -438,12 +454,13 @@ export class SessionRuntime {
438
454
  this.shownToolIds = new Set();
439
455
  this.fileOps = new Map();
440
456
  this.subagentShown = new Map();
457
+ this.progress = undefined; // a new turn = a new task; clear the old bar
441
458
  // A new streamed turn supersedes any transient "follow" watch of this same
442
459
  // session's previous in-flight turn (avoids duplicated output).
443
460
  if (this.watchIsFollow) this.stopWatch();
444
461
  const live = this.foreground;
445
462
  this.streamer = live
446
- ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags())
463
+ ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct))
447
464
  : undefined;
448
465
  if (live) this.typing.start();
449
466
  this.activity(true);
@@ -456,6 +473,7 @@ export class SessionRuntime {
456
473
  const content = buildContentBlocks(input, {
457
474
  reasoning: reasoningDirective(this.reasoning),
458
475
  priming: this.primingContext,
476
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
459
477
  });
460
478
  this.primingContext = undefined;
461
479
 
@@ -470,13 +488,18 @@ export class SessionRuntime {
470
488
  // to this session can replay its Done + summary). Only PING the chat for
471
489
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
472
490
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
491
+ // A background session about to run a queued follow-up shouldn't ping its
492
+ // interim "Done" — only the final, queue-empty turn announces completion.
493
+ const hasQueued = this.queue.length > 0;
494
+ const switchKb = this.switchKeyboard();
473
495
  if (final.result || this.cancelled) {
474
496
  const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
475
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
497
+ const pingDone = canPing && (this.foreground || !hasQueued);
498
+ if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
476
499
  } else if (final.error) {
477
500
  const transient = isTransientAcpError(final.error);
478
501
  const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
479
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
502
+ if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
480
503
  }
481
504
  } catch (err) {
482
505
  // Unexpected failure outside the prompt path (e.g. while finalizing).
@@ -485,7 +508,7 @@ export class SessionRuntime {
485
508
  this.lastCompletion = msg;
486
509
  if (this.foreground || this.cfg.notifyOtherSessions) {
487
510
  const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
488
- await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo });
511
+ await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
489
512
  }
490
513
  } finally {
491
514
  this.typing.stop();
@@ -589,6 +612,7 @@ export class SessionRuntime {
589
612
  const forkContent = buildContentBlocks(input, {
590
613
  reasoning: reasoningDirective(this.reasoning),
591
614
  priming: transcript ? buildPriming(transcript) : undefined,
615
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
592
616
  });
593
617
  return this.runPromptWithRetries(forkContent);
594
618
  }
@@ -708,6 +732,14 @@ export class SessionRuntime {
708
732
  return `[${name}${id}]`;
709
733
  }
710
734
 
735
+ /** Inline keyboard offering to switch to this session, attached to background
736
+ * ("From other session") pings so you can jump straight in. Foreground turns
737
+ * are already in view, so they get no button. */
738
+ private switchKeyboard(): InlineKeyboard | undefined {
739
+ if (this.foreground || !this.sessionId) return undefined;
740
+ return new InlineKeyboard().text("\u{1F500} Switch to this session", `run:switch:${this.sessionId}`);
741
+ }
742
+
711
743
  /** Searchable Telegram hashtags so you can pull up every message of a session
712
744
  * or project by tapping the tag. */
713
745
  private hashtags(): string {
@@ -793,12 +825,16 @@ export class SessionRuntime {
793
825
  }
794
826
  }
795
827
 
796
- private async notify(text: string, opts?: { loud?: boolean; replyTo?: number }): Promise<void> {
828
+ private async notify(
829
+ text: string,
830
+ opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
831
+ ): Promise<void> {
797
832
  try {
798
833
  const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
799
834
  if (opts?.replyTo !== undefined) {
800
835
  extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
801
836
  }
837
+ if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
802
838
  await this.api.sendMessage(this.chatId, text, extra);
803
839
  } catch {
804
840
  /* non-fatal */
package/src/config.ts CHANGED
@@ -112,6 +112,8 @@ export interface AppConfig {
112
112
  mcpProbeConcurrency: number;
113
113
  /** Show subagent (crew) activity while the main agent waits on them. */
114
114
  showSubagents: boolean;
115
+ /** Ask the agent to emit a `{progress: N%}` marker and render it as a bar. */
116
+ showProgress: boolean;
115
117
  /** Deliver a turn's "Done" summary to the chat even when that session is in
116
118
  * the background (you've switched to another session). */
117
119
  notifyOtherSessions: boolean;
@@ -182,6 +184,7 @@ export function loadConfig(): AppConfig {
182
184
  mcpProbeTimeoutMs: num(process.env.MCP_PROBE_TIMEOUT_MS, 8000),
183
185
  mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6),
184
186
  showSubagents: bool(process.env.SHOW_SUBAGENTS, true),
187
+ showProgress: bool(process.env.SHOW_PROGRESS, true),
185
188
  notifyOtherSessions: bool(process.env.NOTIFY_OTHER_SESSIONS, true),
186
189
  autoUpdate: bool(process.env.AUTO_UPDATE, true),
187
190
  updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),