kiro-telegram-bot 1.5.1 → 1.6.0

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.
Files changed (43) hide show
  1. package/.env.example +30 -0
  2. package/CHANGELOG.md +310 -0
  3. package/README.md +8 -3
  4. package/package.json +2 -1
  5. package/src/acp/client.ts +24 -0
  6. package/src/app/settings-store.ts +7 -0
  7. package/src/app/types.ts +4 -2
  8. package/src/app/updater.ts +234 -0
  9. package/src/app/version.ts +41 -0
  10. package/src/bot/bot.ts +53 -1
  11. package/src/bot/chat-controller.ts +60 -7
  12. package/src/bot/commands.ts +3 -3
  13. package/src/bot/deps.ts +18 -0
  14. package/src/bot/handlers/control.ts +11 -3
  15. package/src/bot/handlers/history.ts +7 -2
  16. package/src/bot/handlers/kill.ts +4 -2
  17. package/src/bot/handlers/mcp.ts +2 -1
  18. package/src/bot/handlers/menu.ts +12 -7
  19. package/src/bot/handlers/message.ts +7 -2
  20. package/src/bot/handlers/photo.ts +13 -4
  21. package/src/bot/handlers/projects.ts +119 -19
  22. package/src/bot/handlers/running.ts +96 -21
  23. package/src/bot/handlers/sessions.ts +41 -25
  24. package/src/bot/handlers/tasks.ts +2 -1
  25. package/src/bot/handlers/usage.ts +2 -1
  26. package/src/bot/handlers/voice.ts +1 -1
  27. package/src/bot/menu/ephemeral.ts +117 -0
  28. package/src/bot/prompt-content.ts +1 -0
  29. package/src/bot/session-fork.ts +35 -0
  30. package/src/bot/session-runtime.ts +228 -66
  31. package/src/config.ts +21 -0
  32. package/src/index.ts +3 -1
  33. package/src/projects/manager.ts +16 -5
  34. package/src/render/file-summary.ts +111 -0
  35. package/src/render/hashtags.ts +34 -0
  36. package/src/render/markdown.ts +4 -0
  37. package/src/render/tool-call.ts +97 -3
  38. package/src/service/linux.ts +2 -0
  39. package/src/service/macos.ts +10 -0
  40. package/src/service/platform.ts +5 -0
  41. package/src/service/types.ts +2 -0
  42. package/src/sessions/history.ts +33 -0
  43. package/src/stream/streamer.ts +34 -8
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Auto-updater — once an hour, asks npm for the latest published version with a
3
+ * single lightweight request. When a newer version exists AND the bot is fully
4
+ * idle (no in-flight prompt, no other active Kiro session on the PC), it
5
+ * announces in chat, runs `npm install -g kiro-telegram-bot@<latest>`, and
6
+ * restarts to apply. After the restart it posts the new version's CHANGELOG
7
+ * (tagged #update) so every release is easy to find in the conversation.
8
+ *
9
+ * Safety:
10
+ * • only ever updates when idle — never interrupts a running turn/task;
11
+ * • only for a global npm install (a cloned/source checkout is left alone);
12
+ * • restart is supervisor-aware: under systemd/launchd it exits cleanly and
13
+ * lets the supervisor relaunch; on Windows / foreground it re-execs itself.
14
+ */
15
+ import { spawn } from "node:child_process";
16
+ import { get } from "node:https";
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ import { JsonStore } from "./json-store.js";
20
+ import { createLogger } from "../logger.js";
21
+ import { extractChangelog, isNewer, isSafeVersion } from "./version.js";
22
+
23
+ const log = createLogger("updater");
24
+ const PKG = "kiro-telegram-bot";
25
+
26
+ interface PendingUpdate {
27
+ from: string;
28
+ to: string;
29
+ chats: number[];
30
+ }
31
+
32
+ export interface UpdaterOptions {
33
+ enabled: boolean;
34
+ intervalMs: number;
35
+ projectRoot: string;
36
+ instanceDir: string;
37
+ dataDir: string;
38
+ /** True while the agent is busy (a chat turn or scheduled task). */
39
+ isPromptInFlight: () => boolean;
40
+ /** Active Kiro sessions on this PC NOT owned by the bot's own agent. */
41
+ otherActiveSessions: () => number;
42
+ /** Send a plain or markdown message to every chat. */
43
+ announce: (text: string, markdown: boolean) => Promise<void>;
44
+ /** Stop polling + the agent before the process exits/re-execs. */
45
+ shutdown: () => Promise<void>;
46
+ }
47
+
48
+ export class Updater {
49
+ private timer: NodeJS.Timeout | undefined;
50
+ private readonly state: JsonStore<PendingUpdate | null>;
51
+ private readonly current: string;
52
+ private attempting = false;
53
+ private readonly tried = new Set<string>();
54
+
55
+ constructor(private readonly opts: UpdaterOptions) {
56
+ this.state = new JsonStore<PendingUpdate | null>(join(opts.dataDir, "update-state.json"), null);
57
+ this.current = readVersion(opts.projectRoot);
58
+ }
59
+
60
+ /** Announce a just-applied update (if any), then begin hourly checks. */
61
+ async start(): Promise<void> {
62
+ await this.announcePending();
63
+ if (!this.opts.enabled) {
64
+ log.info("auto-update disabled (AUTO_UPDATE=false)");
65
+ return;
66
+ }
67
+ if (!this.isNpmInstall()) {
68
+ log.info("running from source — auto-update is a no-op (use git to update)");
69
+ return;
70
+ }
71
+ // First check shortly after boot, then on the configured interval.
72
+ this.timer = setTimeout(() => void this.tick(), 60_000);
73
+ }
74
+
75
+ stop(): void {
76
+ if (this.timer) clearTimeout(this.timer);
77
+ this.timer = undefined;
78
+ }
79
+
80
+ private schedule(): void {
81
+ this.timer = setTimeout(() => void this.tick(), this.opts.intervalMs);
82
+ }
83
+
84
+ private async tick(): Promise<void> {
85
+ try {
86
+ await this.checkAndUpdate();
87
+ } catch (e) {
88
+ log.debug("update check failed:", (e as Error).message);
89
+ } finally {
90
+ this.schedule();
91
+ }
92
+ }
93
+
94
+ private async checkAndUpdate(): Promise<void> {
95
+ if (this.attempting) return;
96
+ const latest = await fetchLatestVersion();
97
+ if (!latest || !isSafeVersion(latest)) return;
98
+ if (!isNewer(latest, this.current)) return;
99
+ if (this.tried.has(latest)) return; // don't loop on a version we already tried
100
+
101
+ if (this.opts.isPromptInFlight() || this.opts.otherActiveSessions() > 0) {
102
+ log.info(`update ${this.current} -> ${latest} available; waiting for idle`);
103
+ return; // re-evaluated next interval
104
+ }
105
+ await this.applyUpdate(latest);
106
+ }
107
+
108
+ private async applyUpdate(latest: string): Promise<void> {
109
+ this.attempting = true;
110
+ this.tried.add(latest);
111
+ log.info(`updating ${this.current} -> ${latest}`);
112
+ await this.opts.announce(
113
+ `\u{1F504} #update Updating ${PKG} v${this.current} \u2192 v${latest}\u2026\nThe bot is idle, so it's safe \u2014 it will restart and report what changed.`,
114
+ false,
115
+ );
116
+
117
+ const ok = await npmInstall(latest);
118
+ if (!ok) {
119
+ this.attempting = false;
120
+ await this.opts.announce(
121
+ `\u26A0\uFE0F #update Update to v${latest} failed (\`npm install -g\`). I'll try again after the next restart.`,
122
+ false,
123
+ );
124
+ return;
125
+ }
126
+
127
+ this.state.set({ from: this.current, to: latest, chats: this.announceChats() });
128
+ await this.restart();
129
+ }
130
+
131
+ /** After a restart, post the new version's changelog (once), tagged #update. */
132
+ private async announcePending(): Promise<void> {
133
+ const pending = this.state.get();
134
+ if (!pending) return;
135
+ this.state.set(null); // consume regardless, so we never re-announce
136
+ if (pending.to !== this.current) {
137
+ log.warn(`pending update to ${pending.to} but running ${this.current}; skipping announce`);
138
+ return;
139
+ }
140
+ const notes = this.changelogFor(pending.to);
141
+ const body = notes
142
+ ? `\u{1F680} #update Updated v${pending.from} \u2192 **v${pending.to}**\n\n${notes}`
143
+ : `\u{1F680} #update Updated to **v${pending.to}**.`;
144
+ await this.opts.announce(body, true);
145
+ }
146
+
147
+ private async restart(): Promise<void> {
148
+ await this.opts.shutdown().catch(() => {});
149
+ // Under systemd/launchd, a clean exit triggers a managed relaunch (no double
150
+ // instance). On Windows / foreground there is no supervisor, so re-exec.
151
+ if (process.env.KIRO_TG_SUPERVISED === "1") {
152
+ log.info("exiting for supervisor to relaunch the updated bot");
153
+ setTimeout(() => process.exit(0), 250);
154
+ return;
155
+ }
156
+ log.info("re-executing the updated bot");
157
+ const child = spawn(
158
+ process.execPath,
159
+ ["--import", "tsx", join(this.opts.projectRoot, "src", "index.ts"), "--instance", this.opts.instanceDir],
160
+ { detached: true, stdio: "ignore", cwd: this.opts.projectRoot, env: process.env },
161
+ );
162
+ child.unref();
163
+ setTimeout(() => process.exit(0), 500);
164
+ }
165
+
166
+ private isNpmInstall(): boolean {
167
+ return this.opts.projectRoot.replace(/\\/g, "/").includes("/node_modules/");
168
+ }
169
+
170
+ private announceChats(): number[] {
171
+ const pending = this.state.get();
172
+ return pending?.chats ?? [];
173
+ }
174
+
175
+ private changelogFor(version: string): string {
176
+ try {
177
+ const md = readFileSync(join(this.opts.projectRoot, "CHANGELOG.md"), "utf-8");
178
+ return extractChangelog(md, version);
179
+ } catch {
180
+ return "";
181
+ }
182
+ }
183
+ }
184
+
185
+ /** Read the installed version from package.json (falls back to "0.0.0"). */
186
+ function readVersion(projectRoot: string): string {
187
+ try {
188
+ return (JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8")) as { version?: string }).version ?? "0.0.0";
189
+ } catch {
190
+ return "0.0.0";
191
+ }
192
+ }
193
+
194
+ /** One small HTTPS GET to the npm registry's dist-tag manifest for `latest`. */
195
+ function fetchLatestVersion(): Promise<string | undefined> {
196
+ return new Promise((resolve) => {
197
+ const req = get(
198
+ `https://registry.npmjs.org/${PKG}/latest`,
199
+ { timeout: 10_000, headers: { Accept: "application/json" } },
200
+ (res) => {
201
+ if (res.statusCode !== 200) {
202
+ res.resume();
203
+ resolve(undefined);
204
+ return;
205
+ }
206
+ let body = "";
207
+ res.setEncoding("utf-8");
208
+ res.on("data", (c) => (body += c));
209
+ res.on("end", () => {
210
+ try {
211
+ resolve((JSON.parse(body) as { version?: string }).version);
212
+ } catch {
213
+ resolve(undefined);
214
+ }
215
+ });
216
+ },
217
+ );
218
+ req.on("error", () => resolve(undefined));
219
+ req.on("timeout", () => {
220
+ req.destroy();
221
+ resolve(undefined);
222
+ });
223
+ });
224
+ }
225
+
226
+ /** Run `npm install -g kiro-telegram-bot@<version>`; resolves true on success. */
227
+ function npmInstall(version: string): Promise<boolean> {
228
+ return new Promise((resolve) => {
229
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
230
+ const child = spawn(npm, ["install", "-g", `${PKG}@${version}`], { stdio: "ignore" });
231
+ child.on("error", () => resolve(false));
232
+ child.on("exit", (code) => resolve(code === 0));
233
+ });
234
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Tiny semver helpers + CHANGELOG section extraction for the auto-updater.
3
+ * Pure and dependency-free so they're easy to test.
4
+ */
5
+
6
+ /** Parse the leading "X.Y.Z" of a version string (ignores pre-release tags). */
7
+ export function parseSemver(v: string): [number, number, number] {
8
+ const m = /^\s*v?(\d+)\.(\d+)\.(\d+)/.exec(v);
9
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : [0, 0, 0];
10
+ }
11
+
12
+ /** True when `latest` is strictly greater than `current` (by major/minor/patch). */
13
+ export function isNewer(latest: string, current: string): boolean {
14
+ const a = parseSemver(latest);
15
+ const b = parseSemver(current);
16
+ for (let i = 0; i < 3; i++) {
17
+ if (a[i] !== b[i]) return a[i]! > b[i]!;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ /** A version string is a plain semver we'd trust to pass to `npm install`. */
23
+ export function isSafeVersion(v: string): boolean {
24
+ return /^\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(v);
25
+ }
26
+
27
+ /** Extract the body of a CHANGELOG `## [version] …` section (markdown). */
28
+ export function extractChangelog(md: string, version: string): string {
29
+ const head = new RegExp(`^##\\s*\\[${version.replace(/\./g, "\\.")}\\]`);
30
+ const out: string[] = [];
31
+ let capturing = false;
32
+ for (const line of md.split("\n")) {
33
+ if (capturing && /^##\s*\[/.test(line)) break;
34
+ if (capturing) {
35
+ out.push(line);
36
+ continue;
37
+ }
38
+ if (head.test(line)) capturing = true;
39
+ }
40
+ return out.join("\n").trim();
41
+ }
package/src/bot/bot.ts CHANGED
@@ -7,8 +7,10 @@ import { Bot } from "grammy";
7
7
  import type { AcpClient } from "../acp/client.js";
8
8
  import { SettingsStore } from "../app/settings-store.js";
9
9
  import { SttService } from "../app/stt.js";
10
+ import { Updater } from "../app/updater.js";
10
11
  import { UsageService } from "../app/usage.js";
11
12
  import type { AppConfig } from "../config.js";
13
+ import { INSTANCE_DIR } from "../config.js";
12
14
  import { createLogger } from "../logger.js";
13
15
  import { ProjectManager } from "../projects/manager.js";
14
16
  import { SessionStore } from "../sessions/store.js";
@@ -33,6 +35,9 @@ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
33
35
  import { registerUsage } from "./handlers/usage.js";
34
36
  import { registerVoice } from "./handlers/voice.js";
35
37
  import { StatusPanel } from "./menu/status-panel.js";
38
+ import { sendMarkdownDoc } from "./telegram-io.js";
39
+ import { Ephemeral } from "./menu/ephemeral.js";
40
+ import { BAR_LABELS } from "./menu/keyboard.js";
36
41
  import { PermissionService } from "./permission-service.js";
37
42
  import { RuntimeRegistry } from "./registry.js";
38
43
  import { TaskWizard } from "./wizard/task-wizard.js";
@@ -57,6 +62,7 @@ export interface BotBundle {
57
62
  bot: Bot;
58
63
  registry: RuntimeRegistry;
59
64
  scheduler: Scheduler;
65
+ updater: Updater;
60
66
  }
61
67
 
62
68
  export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBundle> {
@@ -94,6 +100,7 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
94
100
  menuCache: new MenuCache(),
95
101
  settings,
96
102
  statusPanel,
103
+ ephemeral: new Ephemeral(bot.api, cfg.dataDir),
97
104
  tasks,
98
105
  taskRunner,
99
106
  wizard,
@@ -112,6 +119,16 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
112
119
 
113
120
  bot.use(createAuthMiddleware(cfg));
114
121
 
122
+ // Keep history clean: after handling, delete the user's command (/…) and
123
+ // persistent-bar button taps. Plain prompts and wizard input are kept.
124
+ bot.on("message:text", async (ctx, next) => {
125
+ await next();
126
+ const text = ctx.message?.text ?? "";
127
+ if (text.startsWith("/") || BAR_LABELS.includes(text)) {
128
+ await ctx.deleteMessage().catch(() => {});
129
+ }
130
+ });
131
+
115
132
  bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
116
133
  const label = permissions.resolveChoice(ctx.match![1]!, Number(ctx.match![2]));
117
134
  await ctx.answerCallbackQuery({ text: label ?? "Expired" });
@@ -150,5 +167,40 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
150
167
  log.warn("setMyCommands failed:", (e as Error).message);
151
168
  }
152
169
 
153
- return { bot, registry, scheduler: new Scheduler(tasks, taskRunner) };
170
+ const updater = new Updater({
171
+ enabled: cfg.autoUpdate,
172
+ intervalMs: cfg.updateCheckMs,
173
+ projectRoot: cfg.projectRoot,
174
+ instanceDir: INSTANCE_DIR,
175
+ dataDir: cfg.dataDir,
176
+ isPromptInFlight: () => acp.hasInflightPrompt(),
177
+ otherActiveSessions: () => store.listActive().filter((s) => s.lockPid !== acp.pid).length,
178
+ announce: async (text, markdown) => {
179
+ for (const id of settings.chatIds()) {
180
+ try {
181
+ if (markdown) await sendMarkdownDoc(bot.api, id, text, { loud: true });
182
+ else await bot.api.sendMessage(id, text, { disable_notification: false });
183
+ } catch {
184
+ /* per-chat best-effort */
185
+ }
186
+ }
187
+ },
188
+ shutdown: async () => {
189
+ try {
190
+ await bot.stop();
191
+ } catch {
192
+ /* ignore */
193
+ }
194
+ try {
195
+ acp.stop();
196
+ } catch {
197
+ /* ignore */
198
+ }
199
+ },
200
+ });
201
+
202
+ // Remove any navigation surface left over from before a restart.
203
+ void deps.ephemeral.cleanupAll().catch(() => {});
204
+
205
+ return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
154
206
  }
@@ -64,6 +64,7 @@ export class ChatController {
64
64
  /** List the controlled sessions (for /running). */
65
65
  list(): RunningSession[] {
66
66
  this.ensureRestored();
67
+ this.pruneDuplicates();
67
68
  return this.runtimes.map((rt) => ({
68
69
  sessionId: rt.sessionId,
69
70
  projectName: rt.projectName ?? basename(rt.cwd),
@@ -76,10 +77,11 @@ export class ChatController {
76
77
  /** Start a brand-new session and bring it to the foreground. */
77
78
  async addNew(cwd: string, projectName?: string): Promise<SessionRuntime> {
78
79
  this.ensureRestored();
79
- await this.background(this.fg);
80
+ const prevFg = this.fg;
80
81
  const rt = this.create({ cwd, projectName });
81
82
  this.runtimes.push(rt);
82
83
  this.fg = rt;
84
+ await this.background(prevFg);
83
85
  await rt.startNewSession(cwd, projectName);
84
86
  this.markSeen(rt);
85
87
  this.persist();
@@ -101,10 +103,13 @@ export class ChatController {
101
103
  const sw = await this.switchTo(sessionId);
102
104
  return { rt: sw!.rt, result: "resumed", alreadyControlled: true };
103
105
  }
104
- await this.background(this.fg);
106
+ // Reserve the runtime synchronously (before any await) so a concurrent tap
107
+ // on the same session finds it and switches instead of creating a duplicate.
108
+ const prevFg = this.fg;
105
109
  const rt = this.create({ cwd, projectName, sessionId });
106
110
  this.runtimes.push(rt);
107
111
  this.fg = rt;
112
+ await this.background(prevFg);
108
113
  const result = await rt.attach(sessionId, cwd, projectName, priorEntries);
109
114
  this.markSeen(rt);
110
115
  this.persist();
@@ -117,10 +122,11 @@ export class ChatController {
117
122
  if (this.runtimes.some((r) => r.sessionId === sessionId)) {
118
123
  return (await this.switchTo(sessionId))!;
119
124
  }
120
- await this.background(this.fg);
125
+ const prevFg = this.fg;
121
126
  const rt = this.create({ cwd, projectName, sessionId });
122
127
  this.runtimes.push(rt);
123
128
  this.fg = rt;
129
+ await this.background(prevFg);
124
130
  await rt.prepare().catch(() => {});
125
131
  const path = this.store.jsonlPath(sessionId);
126
132
  const unread = readHistory(path, 12);
@@ -198,8 +204,10 @@ export class ChatController {
198
204
  if (this.restored) return;
199
205
  this.restored = true;
200
206
  const s = this.settings.get(this.chatId);
207
+ const seen = new Set<string>();
201
208
  for (const cs of s.controlledSessions ?? []) {
202
- if (!cs.sessionId) continue;
209
+ if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
210
+ seen.add(cs.sessionId);
203
211
  this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
204
212
  }
205
213
  if (this.runtimes.length > 0) {
@@ -209,10 +217,51 @@ export class ChatController {
209
217
  }
210
218
  }
211
219
 
220
+ /** Drop any runtime that duplicates another's sessionId (keeping the
221
+ * foreground one), healing a state where two runtimes wrap one session. */
222
+ private pruneDuplicates(): void {
223
+ const byId = new Map<string, SessionRuntime>();
224
+ const kept: SessionRuntime[] = [];
225
+ for (const rt of this.runtimes) {
226
+ const id = rt.sessionId;
227
+ if (!id) {
228
+ kept.push(rt);
229
+ continue;
230
+ }
231
+ const prev = byId.get(id);
232
+ if (!prev) {
233
+ byId.set(id, rt);
234
+ kept.push(rt);
235
+ continue;
236
+ }
237
+ const loser = prev.isForeground || !rt.isForeground ? rt : prev;
238
+ const winner = loser === rt ? prev : rt;
239
+ if (winner !== prev) {
240
+ byId.set(id, winner);
241
+ const i = kept.indexOf(prev);
242
+ if (i !== -1) kept[i] = winner;
243
+ }
244
+ if (this.fg === loser) this.fg = winner;
245
+ loser.dispose();
246
+ }
247
+ if (kept.length !== this.runtimes.length) {
248
+ this.runtimes.length = 0;
249
+ this.runtimes.push(...kept);
250
+ this.persist();
251
+ }
252
+ }
253
+
212
254
  private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
213
255
  const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
214
256
  rt.onStateChange = () => this.refresh(this.chatId);
215
257
  rt.onActivity = (busy) => this.notifyActivity(busy);
258
+ // A logical fork (auto-fork-on-error / lost-session recovery) swaps the
259
+ // runtime's session id in place — re-persist the controlled list with the
260
+ // new id and treat the fresh session as already-seen.
261
+ rt.onSessionChange = () => {
262
+ this.markSeen(rt);
263
+ this.persist();
264
+ };
216
265
  return rt;
217
266
  }
218
267
 
@@ -234,9 +283,13 @@ export class ChatController {
234
283
  }
235
284
 
236
285
  private persist(): void {
237
- const controlled = this.runtimes
238
- .filter((r) => r.sessionId)
239
- .map((r) => ({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName }));
286
+ const seen = new Set<string>();
287
+ const controlled: { sessionId?: string; projectPath: string; projectName?: string }[] = [];
288
+ for (const r of this.runtimes) {
289
+ if (!r.sessionId || seen.has(r.sessionId)) continue;
290
+ seen.add(r.sessionId);
291
+ controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
292
+ }
240
293
  this.settings.update(this.chatId, {
241
294
  controlledSessions: controlled,
242
295
  foregroundSessionId: this.fg?.sessionId,
@@ -4,7 +4,7 @@
4
4
  export const COMMANDS: { command: string; description: string }[] = [
5
5
  { command: "start", description: "Welcome, menu & status panel" },
6
6
  { command: "menu", description: "Show the menu keyboard" },
7
- { command: "projects", description: "Projects: list / search <q> / new <name>" },
7
+ { command: "projects", description: "Projects: list / search <q> / open <path> / new <name>" },
8
8
  { command: "sessions", description: "List/resume sessions (active first) \u00b7 /sessions <q>" },
9
9
  { command: "active", description: "Sessions running now on the PC" },
10
10
  { command: "running", description: "Sessions this chat controls \u2014 switch between them" },
@@ -16,7 +16,7 @@ export const COMMANDS: { command: string; description: string }[] = [
16
16
  { command: "new", description: "Start a fresh session here" },
17
17
  { command: "status", description: "Current session, project & queue" },
18
18
  { command: "usage", description: "Account & context usage" },
19
- { command: "btw", description: "Queue a follow-up: /btw <text>" },
19
+ { command: "btw", description: "Run ASAP (now if idle, else next): /btw <text>" },
20
20
  { command: "flush", description: "Send queued follow-ups now" },
21
21
  { command: "queue", description: "Show queued follow-ups" },
22
22
  { command: "cancel", description: "Stop the current turn" },
@@ -41,7 +41,7 @@ export const HELP_TEXT = [
41
41
  "/active \u2014 attach to a session currently running on the PC",
42
42
  "/history \u2014 show the latest messages of the current session",
43
43
  "/new \u2014 start a brand-new session in the current project",
44
- "/btw <text> \u2014 add a follow-up to run after the current task",
44
+ "/btw <text> \u2014 run it now if idle, otherwise right after the current task",
45
45
  "/flush \u2014 run queued follow-ups immediately",
46
46
  "/cancel \u2014 stop the current turn",
47
47
  "/status \u2014 show session, project and queue size",
package/src/bot/deps.ts CHANGED
@@ -9,10 +9,12 @@ import type { AppConfig } from "../config.js";
9
9
  import type { SttService } from "../app/stt.js";
10
10
  import type { UsageService } from "../app/usage.js";
11
11
  import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
12
+ import type { SessionMeta } from "../sessions/types.js";
12
13
  import type { SessionStore } from "../sessions/store.js";
13
14
  import type { TaskRunner } from "../tasks/runner.js";
14
15
  import type { TaskStore } from "../tasks/store.js";
15
16
  import type { StatusPanel } from "./menu/status-panel.js";
17
+ import type { Ephemeral } from "./menu/ephemeral.js";
16
18
  import type { RuntimeRegistry } from "./registry.js";
17
19
  import type { TaskWizard } from "./wizard/task-wizard.js";
18
20
 
@@ -26,6 +28,7 @@ export interface BotDeps {
26
28
  menuCache: MenuCache;
27
29
  settings: SettingsStore;
28
30
  statusPanel: StatusPanel;
31
+ ephemeral: Ephemeral;
29
32
  tasks: TaskStore;
30
33
  taskRunner: TaskRunner;
31
34
  wizard: TaskWizard;
@@ -36,6 +39,7 @@ export interface BotDeps {
36
39
  /** Caches the last project list shown per chat for callback resolution. */
37
40
  export class MenuCache {
38
41
  private readonly projectLists = new Map<number, ProjectEntry[]>();
42
+ private readonly sessionLists = new Map<number, { metas: SessionMeta[]; heading: string }>();
39
43
 
40
44
  setProjects(chatId: number, list: ProjectEntry[]): void {
41
45
  this.projectLists.set(chatId, list);
@@ -44,4 +48,18 @@ export class MenuCache {
44
48
  getProject(chatId: number, index: number): ProjectEntry | undefined {
45
49
  return this.projectLists.get(chatId)?.[index];
46
50
  }
51
+
52
+ /** The full (sorted) project list, for paging the picker. */
53
+ getProjects(chatId: number): ProjectEntry[] | undefined {
54
+ return this.projectLists.get(chatId);
55
+ }
56
+
57
+ /** Remember the session set + heading currently being paged for a chat. */
58
+ setSessions(chatId: number, metas: SessionMeta[], heading: string): void {
59
+ this.sessionLists.set(chatId, { metas, heading });
60
+ }
61
+
62
+ getSessions(chatId: number): { metas: SessionMeta[]; heading: string } | undefined {
63
+ return this.sessionLists.get(chatId);
64
+ }
47
65
  }
@@ -68,12 +68,20 @@ export function registerControl(bot: Bot, deps: BotDeps): void {
68
68
  bot.command("btw", async (ctx) => {
69
69
  const text = (ctx.match || "").toString().trim();
70
70
  if (!text) {
71
- await ctx.reply("Usage: /btw <something to do after the current task>");
71
+ await ctx.reply("Usage: /btw <something for the agent to do now if idle, otherwise next>");
72
72
  return;
73
73
  }
74
74
  const rt = deps.registry.get(ctx.chat.id);
75
- rt.enqueue(textPrompt(text));
76
- await ctx.reply(`\u{1F4E5} Queued (position ${rt.queueLength}). It'll run when the current task finishes.`);
75
+ // Run it right away when idle; otherwise queue it to run automatically the
76
+ // moment the current turn finishes (can't interrupt an in-flight agent turn).
77
+ const outcome = await rt.submit(textPrompt(text));
78
+ if (outcome === "queued") {
79
+ await ctx.reply(
80
+ `\u{1F4E5} Queued (position ${rt.queueLength}) \u2014 it'll run automatically as soon as the current task finishes.`,
81
+ );
82
+ } else {
83
+ await ctx.reply("\u25B6\uFE0F On it\u2026");
84
+ }
77
85
  });
78
86
 
79
87
  bot.command("flush", async (ctx) => {
@@ -5,6 +5,7 @@ import type { Bot } from "grammy";
5
5
  import { basename } from "node:path";
6
6
  import type { BotDeps } from "../deps.js";
7
7
  import { readHistory } from "../../sessions/history.js";
8
+ import { sessionHashtags } from "../../render/hashtags.js";
8
9
  import type { SessionMeta } from "../../sessions/types.js";
9
10
  import { sendMarkdownDoc } from "../telegram-io.js";
10
11
 
@@ -24,7 +25,7 @@ export function registerHistory(bot: Bot, deps: BotDeps): void {
24
25
  return;
25
26
  }
26
27
  const meta = deps.store.get(rt.sessionId);
27
- await showHistory(deps, ctx.chat.id, rt.sessionId, meta);
28
+ await showHistory(deps, ctx.chat.id, rt.sessionId, meta, 16, rt.tags);
28
29
  });
29
30
  }
30
31
 
@@ -35,6 +36,7 @@ export async function showHistory(
35
36
  sessionId: string,
36
37
  meta?: SessionMeta,
37
38
  count = 16,
39
+ tags?: string,
38
40
  ): Promise<void> {
39
41
  const entries = readHistory(deps.store.jsonlPath(sessionId), count);
40
42
  if (entries.length === 0) {
@@ -54,5 +56,8 @@ export async function showHistory(
54
56
  })
55
57
  .join("\n\n");
56
58
 
57
- await sendMarkdownDoc(deps.api, chatId, `${header}\n\n${body}`);
59
+ // Every AI-output surface carries the session's searchable hashtags. A static
60
+ // view (no live runtime) tags at least project + session id.
61
+ const footer = tags ?? sessionHashtags({ cwd: meta?.cwd, sessionId });
62
+ await sendMarkdownDoc(deps.api, chatId, `${header}\n\n${body}\n\n${footer}`);
58
63
  }
@@ -17,9 +17,10 @@ function targets(deps: BotDeps): SessionMeta[] {
17
17
  }
18
18
 
19
19
  export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void> {
20
+ await deps.ephemeral.open(ctx);
20
21
  const active = targets(deps);
21
22
  if (active.length === 0) {
22
- await ctx.reply("\u2705 No other active Kiro sessions to kill.");
23
+ await deps.ephemeral.reply(ctx, "\u2705 No other active Kiro sessions to kill.");
23
24
  return;
24
25
  }
25
26
  const list = active
@@ -29,7 +30,8 @@ export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void
29
30
  const kb = new InlineKeyboard()
30
31
  .text(`\u{1F6D1} Kill ${active.length}`, "killall:confirm")
31
32
  .text("Cancel", "killall:cancel");
32
- await ctx.reply(
33
+ await deps.ephemeral.reply(
34
+ ctx,
33
35
  `\u{1F6D1} Kill ${active.length} active session(s)?\n${list}\n\n(The bot's own session is excluded.)`,
34
36
  { reply_markup: kb },
35
37
  );
@@ -87,7 +87,8 @@ function togglePanel(list: McpServer[], page: number): { text: string; kb: Inlin
87
87
  export async function showMcp(ctx: Context, deps: BotDeps): Promise<void> {
88
88
  const list = snapshot(ctx.chat!.id, deps);
89
89
  const { text, kb } = mainPanel(list);
90
- await ctx.reply(text, { reply_markup: kb });
90
+ await deps.ephemeral.open(ctx);
91
+ await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
91
92
  }
92
93
 
93
94
  function fmtProbe(r: McpProbeResult): string {