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.
package/src/bot/bot.ts CHANGED
@@ -30,6 +30,8 @@ import { registerPhotos } from "./handlers/photo.js";
30
30
  import { registerProjects } from "./handlers/projects.js";
31
31
  import { registerRunning, switchAndShow } from "./handlers/running.js";
32
32
  import { registerSessions } from "./handlers/sessions.js";
33
+ import { registerSessionKill } from "./handlers/session-kill.js";
34
+ import { registerReauth } from "./handlers/auth.js";
33
35
  import { registerSystem } from "./handlers/system.js";
34
36
  import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
35
37
  import { registerUsage } from "./handlers/usage.js";
@@ -117,6 +119,11 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
117
119
  const permissions = new PermissionService(bot.api, registry);
118
120
  acp.permissionHandler = (p) => permissions.handle(p);
119
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
+
120
127
  bot.use(createAuthMiddleware(cfg));
121
128
 
122
129
  // Keep history clean: after handling, delete the user's command (/…) and
@@ -146,9 +153,11 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
146
153
  registerControl(bot, deps);
147
154
  registerProjects(bot, deps);
148
155
  registerSessions(bot, deps);
156
+ registerSessionKill(bot, deps);
149
157
  registerRunning(bot, deps);
150
158
  registerHistory(bot, deps);
151
159
  registerSystem(bot, deps);
160
+ registerReauth(bot, deps);
152
161
  registerUsage(bot, deps);
153
162
  registerKill(bot, deps);
154
163
  registerMcp(bot, deps);
@@ -20,6 +20,8 @@ export interface RunningSession {
20
20
  busy: boolean;
21
21
  foreground: boolean;
22
22
  unread: number;
23
+ /** Latest task-completion % (0–100) for this session, if known. */
24
+ progress?: number;
23
25
  }
24
26
 
25
27
  export interface SwitchResult {
@@ -71,6 +73,7 @@ export class ChatController {
71
73
  busy: rt.isBusy,
72
74
  foreground: rt.isForeground,
73
75
  unread: this.unreadCount(rt),
76
+ progress: rt.taskProgress,
74
77
  }));
75
78
  }
76
79
 
@@ -188,6 +191,13 @@ export class ChatController {
188
191
  return this.runtimes.length;
189
192
  }
190
193
 
194
+ /** Latest task-progress % for a controlled session id, if this chat runs it. */
195
+ progressFor(sessionId?: string): number | undefined {
196
+ if (!sessionId) return undefined;
197
+ this.ensureRestored();
198
+ return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
199
+ }
200
+
191
201
  findBySession(sessionId: string): boolean {
192
202
  return this.runtimes.some((r) => r.sessionId === sessionId);
193
203
  }
@@ -23,6 +23,7 @@ export const COMMANDS: { command: string; description: string }[] = [
23
23
  { command: "unwatch", description: "Stop following a live session" },
24
24
  { command: "model", description: "Switch model: /model <id>" },
25
25
  { command: "restart", description: "Restart the Kiro agent" },
26
+ { command: "reauth", description: "Log out & log in to Kiro (device flow)" },
26
27
  { command: "help", description: "Show help" },
27
28
  ];
28
29
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * /reauth — re-authenticate Kiro. Instead of always running one provider's
3
+ * device flow, it first shows a login-method picker (Builder ID, Google,
4
+ * GitHub, IAM Identity Center). The chosen method drives a logout → device-flow
5
+ * login → agent restart on a single, self-animated status message. Inline
6
+ * buttons drive the flow:
7
+ * • Builder ID / Google / GitHub / IAM Identity Center — pick how to log in
8
+ * • Cancel — abort the picker or the in-flight logout/login
9
+ * • Back / Change method — return to the picker
10
+ * • Retry — re-run the last method on the same message
11
+ * • Restart agent — retry just the agent restart after a restart failure
12
+ *
13
+ * Power users can still skip the picker by passing flags directly, e.g.
14
+ * `/reauth --license pro --identity-provider <url> --region <region>`.
15
+ *
16
+ * Guarded: refused while a prompt is in flight (logging out would break the
17
+ * running turn) and serialised per chat so two runs can't overlap.
18
+ */
19
+ import type { Bot } from "grammy";
20
+ import type { BotDeps } from "../deps.js";
21
+ import { type LoginMethod, ReauthController } from "../reauth-controller.js";
22
+
23
+ export function registerReauth(bot: Bot, deps: BotDeps): void {
24
+ const controller = new ReauthController(deps.api, deps.acp, deps.cfg.kiroCliPath, () => deps.usage.account());
25
+
26
+ // IDC start-URL/region text capture. Registered before the catch-all message
27
+ // handler so the reply feeds the reauth flow instead of becoming a prompt.
28
+ bot.on("message:text", async (ctx, next) => {
29
+ const chatId = ctx.chat.id;
30
+ if (!controller.awaitingIdcInput(chatId)) return next();
31
+ const text = ctx.message.text;
32
+ if (text.startsWith("/")) return next(); // let a command through; picker stays
33
+ await controller.submitIdcInput(chatId, text);
34
+ });
35
+
36
+ bot.command("reauth", async (ctx) => {
37
+ if (controller.isBusy(ctx.chat.id)) {
38
+ await ctx.reply("\u{1F510} A re-authentication is already in progress.");
39
+ return;
40
+ }
41
+ const extra = (ctx.match?.toString() ?? "").trim().split(/\s+/).filter(Boolean);
42
+ // Explicit flags skip the picker (advanced / scripted use); otherwise ask.
43
+ if (extra.length > 0) await controller.begin(ctx.chat.id, extra);
44
+ else await controller.chooseMethod(ctx.chat.id);
45
+ });
46
+
47
+ bot.callbackQuery(/^reauth:method:(builder|google|github|idc)$/, async (ctx) => {
48
+ await ctx.answerCallbackQuery();
49
+ const chatId = ctx.chat?.id;
50
+ const messageId = ctx.callbackQuery.message?.message_id;
51
+ if (chatId !== undefined && messageId !== undefined) {
52
+ await controller.pickMethod(chatId, messageId, ctx.match![1] as LoginMethod);
53
+ }
54
+ });
55
+
56
+ bot.callbackQuery("reauth:choose-back", async (ctx) => {
57
+ await ctx.answerCallbackQuery();
58
+ const chatId = ctx.chat?.id;
59
+ const messageId = ctx.callbackQuery.message?.message_id;
60
+ if (chatId !== undefined && messageId !== undefined) await controller.chooseMethod(chatId, messageId);
61
+ });
62
+
63
+ bot.callbackQuery("reauth:choose-cancel", async (ctx) => {
64
+ await ctx.answerCallbackQuery({ text: "Cancelled" });
65
+ const chatId = ctx.chat?.id;
66
+ const messageId = ctx.callbackQuery.message?.message_id;
67
+ if (chatId !== undefined && messageId !== undefined) await controller.cancelChoice(chatId, messageId);
68
+ });
69
+
70
+ bot.callbackQuery("reauth:cancel", async (ctx) => {
71
+ const chatId = ctx.chat?.id;
72
+ const ok = chatId !== undefined && controller.cancel(chatId);
73
+ await ctx.answerCallbackQuery({ text: ok ? "Cancelling\u2026" : "Nothing to cancel" });
74
+ });
75
+
76
+ bot.callbackQuery("reauth:retry", async (ctx) => {
77
+ await ctx.answerCallbackQuery({ text: "Retrying\u2026" });
78
+ const chatId = ctx.chat?.id;
79
+ const messageId = ctx.callbackQuery.message?.message_id;
80
+ if (chatId !== undefined && messageId !== undefined) await controller.retry(chatId, messageId);
81
+ });
82
+
83
+ bot.callbackQuery("reauth:restart", async (ctx) => {
84
+ await ctx.answerCallbackQuery({ text: "Restarting agent\u2026" });
85
+ const chatId = ctx.chat?.id;
86
+ const messageId = ctx.callbackQuery.message?.message_id;
87
+ if (chatId !== undefined && messageId !== undefined) await controller.restartAgent(chatId, messageId);
88
+ });
89
+ }
@@ -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);
@@ -3,14 +3,11 @@
3
3
  * holding a live session lock), excluding the bot's own agent process. Guarded
4
4
  * by an inline confirmation since it kills processes.
5
5
  */
6
- import { execFileSync } from "node:child_process";
7
6
  import { type Bot, type Context, InlineKeyboard } from "grammy";
8
- import { createLogger } from "../../logger.js";
7
+ import { killPid } from "../../sessions/process.js";
9
8
  import type { SessionMeta } from "../../sessions/types.js";
10
9
  import type { BotDeps } from "../deps.js";
11
10
 
12
- const log = createLogger("killall");
13
-
14
11
  function targets(deps: BotDeps): SessionMeta[] {
15
12
  const self = deps.acp.pid;
16
13
  return deps.store.listActive().filter((s) => s.lockPid && s.lockPid !== self);
@@ -55,17 +52,3 @@ export function registerKill(bot: Bot, deps: BotDeps): void {
55
52
  await ctx.editMessageText(`\u{1F6D1} Killed ${killed} of ${active.length} active session(s).`).catch(() => {});
56
53
  });
57
54
  }
58
-
59
- function killPid(pid: number): boolean {
60
- try {
61
- if (process.platform === "win32") {
62
- execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
63
- } else {
64
- process.kill(pid, "SIGKILL");
65
- }
66
- return true;
67
- } catch (e) {
68
- log.debug(`kill ${pid} failed:`, (e as Error).message);
69
- return false;
70
- }
71
- }
@@ -8,6 +8,7 @@ import type { RunningSession, SwitchResult } from "../chat-controller.js";
8
8
  import type { BotDeps } from "../deps.js";
9
9
  import type { HistoryEntry } from "../../sessions/types.js";
10
10
  import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
11
+ import { progressBar } from "../../render/progress.js";
11
12
  import { refreshMenu } from "../menu/refresh.js";
12
13
  import { sendMarkdownDoc } from "../telegram-io.js";
13
14
 
@@ -71,6 +72,7 @@ function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text
71
72
  prompt ? `\u{1F4AC} \u201C${trunc(prompt, 120)}\u201D` : "\u{1F4AC} (no messages yet)",
72
73
  `\u{1F552} ${meta.join(" \u00B7 ")}`,
73
74
  ];
75
+ if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
74
76
  if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
75
77
 
76
78
  const kb = new InlineKeyboard();
@@ -8,11 +8,20 @@
8
8
  */
9
9
  import { InlineKeyboard } from "grammy";
10
10
  import { basename } from "node:path";
11
+ import { progressBar } from "../../render/progress.js";
11
12
  import type { SessionMeta } from "../../sessions/types.js";
12
13
 
13
14
  export interface SessionCardExtras {
14
15
  /** Context-usage %, when the session is loaded in the current ACP process. */
15
16
  contextPct?: number;
17
+ /**
18
+ * PID of the bot's own `kiro-cli acp` process. A session locked by this PID
19
+ * powers the bot itself, so its card omits the Kill button (killing it would
20
+ * take the bot down). Other live sessions get a 🛑 Kill button.
21
+ */
22
+ selfPid?: number;
23
+ /** Latest task-completion % (0–100) for this session, if this chat runs it. */
24
+ progress?: number;
16
25
  }
17
26
 
18
27
  export interface SessionCard {
@@ -31,6 +40,7 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
31
40
  lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
32
41
  const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
33
42
  lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
43
+ if (typeof extra.progress === "number") lines.push(`\u{1F4C8} ${progressBar(extra.progress)}`);
34
44
  lines.push(`\u{1F194} ${m.sessionId.slice(0, 8)}`);
35
45
 
36
46
  const connect = m.active ? "\u{1F374} Continue (fork)" : "\u{1F517} Resume";
@@ -39,6 +49,12 @@ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}):
39
49
  .text("\u{1F4DC} History", `hist:${m.sessionId}`)
40
50
  .text("\u{1F4E1} Watch", `watch:${m.sessionId}`);
41
51
 
52
+ // A live session running in another process can be terminated by PID. The
53
+ // bot's own agent (selfPid) is never offered — killing it would stop the bot.
54
+ if (m.active && typeof m.lockPid === "number" && m.lockPid !== extra.selfPid) {
55
+ keyboard.row().text(`\u{1F6D1} Kill \u00B7 pid ${m.lockPid}`, `killsess:${m.sessionId}`);
56
+ }
57
+
42
58
  return { text: lines.join("\n"), keyboard };
43
59
  }
44
60
 
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Per-session kill — terminate the OS process holding a live session's `.lock`
3
+ * straight from its card in /sessions or /active.
4
+ *
5
+ * Flow (callback data, UUID-keyed so it survives bot restarts):
6
+ * killsess:<id> a tap on the card's 🛑 Kill button → ask to confirm
7
+ * killsess:do:<id> confirmed → kill the lockPid, report the outcome
8
+ * killsess:cancel:<id> abort → restore the card's normal buttons
9
+ *
10
+ * Guards: the bot's own agent (acp.pid) is never killable here (its card never
11
+ * shows the button, and the handlers re-check), and state is re-read from disk
12
+ * at every step so a session that already stopped can't be "killed" twice.
13
+ */
14
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
15
+ import { killPid } from "../../sessions/process.js";
16
+ import type { SessionMeta } from "../../sessions/types.js";
17
+ import type { BotDeps } from "../deps.js";
18
+ import { buildSessionCard } from "./session-card.js";
19
+
20
+ const UUID = "([0-9a-fA-F-]{36})";
21
+
22
+ /** Rebuild the standard card keyboard for the freshest on-disk session state. */
23
+ function cardKeyboard(deps: BotDeps, meta: SessionMeta): InlineKeyboard {
24
+ const contextPct = deps.acp.metadataFor(meta.sessionId)?.contextUsagePercentage;
25
+ return buildSessionCard(meta, { contextPct, selfPid: deps.acp.pid }).keyboard;
26
+ }
27
+
28
+ /** Re-read the session and decide whether its PID may be killed right now. */
29
+ function killable(
30
+ deps: BotDeps,
31
+ id: string,
32
+ ): { ok: true; meta: SessionMeta; pid: number } | { ok: false; meta?: SessionMeta; reason: string } {
33
+ const meta = deps.store.get(id);
34
+ if (!meta) return { ok: false, reason: "Session not found." };
35
+ if (!meta.active || typeof meta.lockPid !== "number") {
36
+ return { ok: false, meta, reason: "Session is no longer running." };
37
+ }
38
+ if (meta.lockPid === deps.acp.pid) {
39
+ return { ok: false, meta, reason: "That's the bot's own agent — can't kill it." };
40
+ }
41
+ return { ok: true, meta, pid: meta.lockPid };
42
+ }
43
+
44
+ export function registerSessionKill(bot: Bot, deps: BotDeps): void {
45
+ // Step 1 — ask to confirm. Swap the card's buttons for a Kill/Cancel row.
46
+ bot.callbackQuery(new RegExp(`^killsess:${UUID}$`), async (ctx) => {
47
+ const id = ctx.match![1]!;
48
+ const check = killable(deps, id);
49
+ if (!check.ok) {
50
+ await ctx.answerCallbackQuery({ text: check.reason });
51
+ // The button is stale (session stopped); refresh it to the normal card.
52
+ if (check.meta) await ctx.editMessageReplyMarkup({ reply_markup: cardKeyboard(deps, check.meta) }).catch(() => {});
53
+ return;
54
+ }
55
+ await ctx.answerCallbackQuery();
56
+ const kb = new InlineKeyboard()
57
+ .text(`\u{1F6D1} Kill pid ${check.pid}`, `killsess:do:${id}`)
58
+ .text("\u21A9 Cancel", `killsess:cancel:${id}`);
59
+ await ctx.editMessageReplyMarkup({ reply_markup: kb }).catch(() => {});
60
+ });
61
+
62
+ // Step 2a — confirmed. Re-validate (it may have died meanwhile), then kill.
63
+ bot.callbackQuery(new RegExp(`^killsess:do:${UUID}$`), async (ctx) => {
64
+ const id = ctx.match![1]!;
65
+ const check = killable(deps, id);
66
+ if (!check.ok) {
67
+ await ctx.answerCallbackQuery({ text: check.reason });
68
+ await appendStatus(ctx, check.reason);
69
+ return;
70
+ }
71
+ const title = check.meta.title;
72
+ const ok = killPid(check.pid);
73
+ await ctx.answerCallbackQuery({ text: ok ? "Killed" : "Kill failed" });
74
+ const note = ok
75
+ ? `\u{1F6D1} Killed ${title} (pid ${check.pid}).`
76
+ : `\u26A0\uFE0F Could not kill pid ${check.pid} (already gone, or not permitted).`;
77
+ await appendStatus(ctx, note);
78
+ });
79
+
80
+ // Step 2b — cancelled. Put the card's normal buttons back.
81
+ bot.callbackQuery(new RegExp(`^killsess:cancel:${UUID}$`), async (ctx) => {
82
+ const id = ctx.match![1]!;
83
+ await ctx.answerCallbackQuery({ text: "Cancelled" });
84
+ const meta = deps.store.get(id);
85
+ if (meta) await ctx.editMessageReplyMarkup({ reply_markup: cardKeyboard(deps, meta) }).catch(() => {});
86
+ else await ctx.editMessageReplyMarkup().catch(() => {});
87
+ });
88
+ }
89
+
90
+ /** Append a one-line status under the card and drop its keyboard. */
91
+ async function appendStatus(ctx: Context, note: string): Promise<void> {
92
+ const body = ctx.callbackQuery?.message?.text;
93
+ const text = body ? `${body}\n\n${note}` : note;
94
+ await ctx.editMessageText(text).catch(() => {});
95
+ }
@@ -52,7 +52,8 @@ async function renderSessionPage(ctx: Context, deps: BotDeps, page: number): Pro
52
52
 
53
53
  for (const m of slice) {
54
54
  const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
55
- const { text, keyboard } = buildSessionCard(m, { contextPct });
55
+ const progress = deps.registry.controller(ctx.chat!.id).progressFor(m.sessionId);
56
+ const { text, keyboard } = buildSessionCard(m, { contextPct, selfPid: deps.acp.pid, progress });
56
57
  await deps.ephemeral.reply(ctx, text, { reply_markup: keyboard });
57
58
  }
58
59
 
@@ -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";
@@ -28,32 +29,54 @@ export class StatusPanel {
28
29
  // after switching between controlled sessions in different projects.
29
30
  const project = rt.projectName || (rt.cwd ? basename(rt.cwd) : "(none)");
30
31
  const session = rt.sessionId ? rt.sessionId.slice(0, 8) : "none";
31
- const state = rt.isBusy ? "\u23F3 working" : "\u2705 idle";
32
- const watch = rt.isWatching ? " \u{1F4E1} watching" : "";
33
32
  const meta = rt.contextInfo();
34
- const ctx = meta?.contextUsagePercentage !== undefined ? `${meta.contextUsagePercentage.toFixed(0)}%` : "\u2014";
33
+ const ctxPct = meta?.contextUsagePercentage;
35
34
  const running = this.registry.controller(chatId).count();
36
- const sessionLine = running > 1 ? `${session} \u{1F9ED} ${running} controlled` : session;
37
- const lines = [
38
- "\u{1F4CA} Kiro \u2014 Status",
39
- `\u{1F4C1} Project: ${project}`,
40
- `\u{1F916} Agent: ${s.agent || "default"}`,
41
- `\u{1F9E0} Reasoning: ${reasoningLabel(s.reasoning)}`,
42
- `\u{1F9E9} Model: ${s.model || "default"}`,
43
- `\u{1F9F5} Session: ${sessionLine}`,
44
- `\u{1F4CA} Context: ${ctx} used`,
45
- `\u2699\uFE0F State: ${state} \u{1F4E5} Queue: ${rt.queueLength}${watch}`,
46
- ];
47
35
  const subagents = this.registry.subagentSummaryForChat(chatId);
48
- if (subagents) lines.push(`\u{1F465} Subagents: ${subagents}`);
36
+ const progress = rt.taskProgress;
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
+
49
61
  return lines.join("\n");
50
62
  }
51
63
 
52
64
  /** Refresh (or create + pin) the status message for a chat. */
53
65
  async refresh(chatId: number): Promise<void> {
54
- const text = this.render(chatId);
66
+ const rt = this.registry.get(chatId);
55
67
  const id = this.settings.get(chatId).statusMessageId;
56
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);
57
80
  if (id) {
58
81
  try {
59
82
  await this.api.editMessageText(chatId, id, text);
@@ -66,6 +89,20 @@ export class StatusPanel {
66
89
  await this.create(chatId, text);
67
90
  }
68
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
+
69
106
  private async create(chatId: number, text: string): Promise<void> {
70
107
  try {
71
108
  const msg = await this.api.sendMessage(chatId, text, { disable_notification: true });
@@ -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;