kiro-telegram-bot 1.5.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.
Files changed (83) hide show
  1. package/.env.example +104 -0
  2. package/LICENSE +21 -0
  3. package/README.md +517 -0
  4. package/bin/kiro-tg.mjs +21 -0
  5. package/docs/INSTALL.md +143 -0
  6. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  7. package/package.json +70 -0
  8. package/scripts/mq.ts +25 -0
  9. package/scripts/setup.mjs +78 -0
  10. package/src/acp/client.ts +456 -0
  11. package/src/acp/server-handlers.ts +85 -0
  12. package/src/acp/transport.ts +50 -0
  13. package/src/acp/types.ts +136 -0
  14. package/src/agents/catalog.ts +44 -0
  15. package/src/app/json-store.ts +54 -0
  16. package/src/app/reasoning.ts +30 -0
  17. package/src/app/settings-store.ts +31 -0
  18. package/src/app/stt.ts +53 -0
  19. package/src/app/types.ts +48 -0
  20. package/src/app/usage.ts +32 -0
  21. package/src/bot/auth.ts +27 -0
  22. package/src/bot/bot.ts +154 -0
  23. package/src/bot/chat-controller.ts +251 -0
  24. package/src/bot/commands.ts +48 -0
  25. package/src/bot/deps.ts +47 -0
  26. package/src/bot/handlers/control.ts +94 -0
  27. package/src/bot/handlers/history.ts +58 -0
  28. package/src/bot/handlers/kill.ts +69 -0
  29. package/src/bot/handlers/mcp.ts +205 -0
  30. package/src/bot/handlers/menu.ts +204 -0
  31. package/src/bot/handlers/message.ts +93 -0
  32. package/src/bot/handlers/photo.ts +108 -0
  33. package/src/bot/handlers/projects.ts +83 -0
  34. package/src/bot/handlers/running.ts +104 -0
  35. package/src/bot/handlers/session-card.ts +65 -0
  36. package/src/bot/handlers/sessions.ts +131 -0
  37. package/src/bot/handlers/system.ts +51 -0
  38. package/src/bot/handlers/tasks.ts +223 -0
  39. package/src/bot/handlers/usage.ts +33 -0
  40. package/src/bot/handlers/voice.ts +53 -0
  41. package/src/bot/image-return.ts +69 -0
  42. package/src/bot/menu/keyboard.ts +47 -0
  43. package/src/bot/menu/refresh.ts +13 -0
  44. package/src/bot/menu/status-panel.ts +78 -0
  45. package/src/bot/permission-service.ts +149 -0
  46. package/src/bot/prompt-content.ts +49 -0
  47. package/src/bot/prompt-retry.ts +70 -0
  48. package/src/bot/registry.ts +178 -0
  49. package/src/bot/session-runtime.ts +670 -0
  50. package/src/bot/telegram-io.ts +109 -0
  51. package/src/bot/typing.ts +35 -0
  52. package/src/bot/wizard/task-wizard.ts +214 -0
  53. package/src/cli.ts +125 -0
  54. package/src/config.ts +190 -0
  55. package/src/index.ts +74 -0
  56. package/src/logger.ts +78 -0
  57. package/src/mcp/config.ts +103 -0
  58. package/src/mcp/probe.ts +218 -0
  59. package/src/mcp/types.ts +68 -0
  60. package/src/projects/manager.ts +88 -0
  61. package/src/render/chunk.ts +57 -0
  62. package/src/render/diff.ts +48 -0
  63. package/src/render/escape.ts +22 -0
  64. package/src/render/markdown.ts +126 -0
  65. package/src/render/subagent.ts +75 -0
  66. package/src/render/tool-call.ts +102 -0
  67. package/src/service/index.ts +24 -0
  68. package/src/service/linux.ts +83 -0
  69. package/src/service/macos.ts +91 -0
  70. package/src/service/platform.ts +59 -0
  71. package/src/service/types.ts +34 -0
  72. package/src/service/windows.ts +103 -0
  73. package/src/sessions/history.ts +181 -0
  74. package/src/sessions/store.ts +133 -0
  75. package/src/sessions/tail.ts +86 -0
  76. package/src/sessions/types.ts +26 -0
  77. package/src/stream/streamer.ts +167 -0
  78. package/src/tasks/runner.ts +82 -0
  79. package/src/tasks/schedule.ts +142 -0
  80. package/src/tasks/scheduler.ts +53 -0
  81. package/src/tasks/store.ts +80 -0
  82. package/src/tasks/types.ts +33 -0
  83. package/tsconfig.json +19 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * ChatController — manages the set of Kiro sessions a single Telegram chat is
3
+ * controlling, with exactly one "foreground" session streaming live. Other
4
+ * (background) sessions keep running quietly; their output lands in the
5
+ * session's .jsonl and is replayed as "unread" when you switch to them.
6
+ */
7
+ import { basename } from "node:path";
8
+ import type { Api } from "grammy";
9
+ import type { AcpClient } from "../acp/client.js";
10
+ import type { SettingsStore } from "../app/settings-store.js";
11
+ import type { AppConfig } from "../config.js";
12
+ import { jsonlSize, readEntriesFrom, readHistory } from "../sessions/history.js";
13
+ import type { SessionStore } from "../sessions/store.js";
14
+ import type { HistoryEntry } from "../sessions/types.js";
15
+ import { SessionRuntime } from "./session-runtime.js";
16
+
17
+ export interface RunningSession {
18
+ sessionId?: string;
19
+ projectName: string;
20
+ busy: boolean;
21
+ foreground: boolean;
22
+ unread: number;
23
+ }
24
+
25
+ export interface SwitchResult {
26
+ rt: SessionRuntime;
27
+ sessionId?: string;
28
+ projectName?: string;
29
+ busy: boolean;
30
+ unread: HistoryEntry[];
31
+ firstView: boolean;
32
+ alreadyForeground: boolean;
33
+ }
34
+
35
+ export class ChatController {
36
+ private readonly runtimes: SessionRuntime[] = [];
37
+ private fg: SessionRuntime | undefined;
38
+ private readonly lastRead = new Map<string, number>();
39
+ private restored = false;
40
+
41
+ constructor(
42
+ private readonly api: Api,
43
+ private readonly chatId: number,
44
+ private readonly acp: AcpClient,
45
+ private readonly cfg: AppConfig,
46
+ private readonly settings: SettingsStore,
47
+ private readonly store: SessionStore,
48
+ private readonly refresh: (chatId: number) => void,
49
+ private readonly notifyActivity: (busy: boolean) => void,
50
+ ) {}
51
+
52
+ /** The current foreground runtime (created/restored lazily). */
53
+ foreground(): SessionRuntime {
54
+ this.ensureRestored();
55
+ if (!this.fg) {
56
+ const s = this.settings.get(this.chatId);
57
+ const rt = this.create({ cwd: s.projectPath ?? this.cfg.workspace, projectName: s.projectName, sessionId: s.sessionId });
58
+ this.runtimes.push(rt);
59
+ this.fg = rt;
60
+ }
61
+ return this.fg;
62
+ }
63
+
64
+ /** List the controlled sessions (for /running). */
65
+ list(): RunningSession[] {
66
+ this.ensureRestored();
67
+ return this.runtimes.map((rt) => ({
68
+ sessionId: rt.sessionId,
69
+ projectName: rt.projectName ?? basename(rt.cwd),
70
+ busy: rt.isBusy,
71
+ foreground: rt.isForeground,
72
+ unread: this.unreadCount(rt),
73
+ }));
74
+ }
75
+
76
+ /** Start a brand-new session and bring it to the foreground. */
77
+ async addNew(cwd: string, projectName?: string): Promise<SessionRuntime> {
78
+ this.ensureRestored();
79
+ await this.background(this.fg);
80
+ const rt = this.create({ cwd, projectName });
81
+ this.runtimes.push(rt);
82
+ this.fg = rt;
83
+ await rt.startNewSession(cwd, projectName);
84
+ this.markSeen(rt);
85
+ this.persist();
86
+ return rt;
87
+ }
88
+
89
+ /**
90
+ * Connect to a session with resume-or-fork semantics (used by /sessions),
91
+ * adding it as a controlled session and bringing it to the foreground.
92
+ */
93
+ async addAttach(
94
+ sessionId: string,
95
+ cwd: string,
96
+ projectName: string | undefined,
97
+ priorEntries: HistoryEntry[],
98
+ ): Promise<{ rt: SessionRuntime; result: "resumed" | "forked"; alreadyControlled: boolean }> {
99
+ this.ensureRestored();
100
+ if (this.runtimes.some((r) => r.sessionId === sessionId)) {
101
+ const sw = await this.switchTo(sessionId);
102
+ return { rt: sw!.rt, result: "resumed", alreadyControlled: true };
103
+ }
104
+ await this.background(this.fg);
105
+ const rt = this.create({ cwd, projectName, sessionId });
106
+ this.runtimes.push(rt);
107
+ this.fg = rt;
108
+ const result = await rt.attach(sessionId, cwd, projectName, priorEntries);
109
+ this.markSeen(rt);
110
+ this.persist();
111
+ return { rt, result, alreadyControlled: false };
112
+ }
113
+
114
+ /** Connect to an existing session: switch if already controlled, else add it. */
115
+ async addResume(sessionId: string, cwd: string, projectName?: string): Promise<SwitchResult> {
116
+ this.ensureRestored();
117
+ if (this.runtimes.some((r) => r.sessionId === sessionId)) {
118
+ return (await this.switchTo(sessionId))!;
119
+ }
120
+ await this.background(this.fg);
121
+ const rt = this.create({ cwd, projectName, sessionId });
122
+ this.runtimes.push(rt);
123
+ this.fg = rt;
124
+ await rt.prepare().catch(() => {});
125
+ const path = this.store.jsonlPath(sessionId);
126
+ const unread = readHistory(path, 12);
127
+ this.lastRead.set(sessionId, jsonlSize(path));
128
+ this.persist();
129
+ return { rt, sessionId, projectName, busy: rt.isBusy, unread, firstView: true, alreadyForeground: false };
130
+ }
131
+
132
+ /** Switch the foreground to an already-controlled session. */
133
+ async switchTo(sessionId: string): Promise<SwitchResult | undefined> {
134
+ this.ensureRestored();
135
+ const rt = this.runtimes.find((r) => r.sessionId === sessionId);
136
+ if (!rt) return undefined;
137
+ if (rt === this.fg) {
138
+ return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread: [], firstView: false, alreadyForeground: true };
139
+ }
140
+ await this.background(this.fg);
141
+ this.fg = rt;
142
+ await rt.setForeground(true);
143
+ await rt.prepare().catch(() => {});
144
+
145
+ const path = this.store.jsonlPath(sessionId);
146
+ const seen = this.lastRead.get(sessionId);
147
+ let unread: HistoryEntry[] = [];
148
+ let firstView = false;
149
+ if (seen !== undefined) {
150
+ unread = readEntriesFrom(path, seen).entries;
151
+ } else {
152
+ unread = readHistory(path, 12);
153
+ firstView = true;
154
+ }
155
+ this.lastRead.set(sessionId, jsonlSize(path));
156
+ // No tail-watch here: setForeground(true) above already resumed RICH live
157
+ // streaming for the in-flight turn via the agent's own session/update
158
+ // events. Tailing the .jsonl too would double-render every update.
159
+ this.persist();
160
+ return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread, firstView, alreadyForeground: false };
161
+ }
162
+
163
+ /** Stop controlling a session (does not kill it). */
164
+ async close(sessionId: string): Promise<boolean> {
165
+ this.ensureRestored();
166
+ const idx = this.runtimes.findIndex((r) => r.sessionId === sessionId);
167
+ if (idx === -1) return false;
168
+ const rt = this.runtimes[idx]!;
169
+ rt.dispose();
170
+ this.runtimes.splice(idx, 1);
171
+ this.lastRead.delete(sessionId);
172
+ if (this.fg === rt) {
173
+ this.fg = this.runtimes[0];
174
+ if (this.fg) await this.fg.setForeground(true);
175
+ }
176
+ this.persist();
177
+ return true;
178
+ }
179
+
180
+ count(): number {
181
+ this.ensureRestored();
182
+ return this.runtimes.length;
183
+ }
184
+
185
+ findBySession(sessionId: string): boolean {
186
+ return this.runtimes.some((r) => r.sessionId === sessionId);
187
+ }
188
+
189
+ dispose(): void {
190
+ for (const rt of this.runtimes) rt.dispose();
191
+ this.runtimes.length = 0;
192
+ this.fg = undefined;
193
+ }
194
+
195
+ // ── internals ──────────────────────────────────────────────────────────────
196
+
197
+ private ensureRestored(): void {
198
+ if (this.restored) return;
199
+ this.restored = true;
200
+ const s = this.settings.get(this.chatId);
201
+ for (const cs of s.controlledSessions ?? []) {
202
+ if (!cs.sessionId) continue;
203
+ this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
204
+ }
205
+ if (this.runtimes.length > 0) {
206
+ const fg = this.runtimes.find((r) => r.sessionId === s.foregroundSessionId) ?? this.runtimes[0]!;
207
+ for (const r of this.runtimes) void r.setForeground(r === fg);
208
+ this.fg = fg;
209
+ }
210
+ }
211
+
212
+ private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
213
+ const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
214
+ rt.onStateChange = () => this.refresh(this.chatId);
215
+ rt.onActivity = (busy) => this.notifyActivity(busy);
216
+ return rt;
217
+ }
218
+
219
+ private async background(rt: SessionRuntime | undefined): Promise<void> {
220
+ if (!rt) return;
221
+ this.markSeen(rt);
222
+ await rt.setForeground(false);
223
+ }
224
+
225
+ private markSeen(rt: SessionRuntime): void {
226
+ if (rt.sessionId) this.lastRead.set(rt.sessionId, jsonlSize(this.store.jsonlPath(rt.sessionId)));
227
+ }
228
+
229
+ private unreadCount(rt: SessionRuntime): number {
230
+ if (!rt.sessionId || rt.isForeground) return 0;
231
+ const seen = this.lastRead.get(rt.sessionId);
232
+ if (seen === undefined) return 0;
233
+ return readEntriesFrom(this.store.jsonlPath(rt.sessionId), seen).entries.length;
234
+ }
235
+
236
+ 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 }));
240
+ this.settings.update(this.chatId, {
241
+ controlledSessions: controlled,
242
+ foregroundSessionId: this.fg?.sessionId,
243
+ // Keep the single-session restore fields aligned with the foreground so
244
+ // the pinned status panel and a fresh restore never show a project that
245
+ // belongs to a different (previously-foreground) session.
246
+ sessionId: this.fg?.sessionId,
247
+ projectPath: this.fg?.cwd,
248
+ projectName: this.fg?.projectName,
249
+ });
250
+ }
251
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Bot command definitions (for the Telegram command menu) and help text.
3
+ */
4
+ export const COMMANDS: { command: string; description: string }[] = [
5
+ { command: "start", description: "Welcome, menu & status panel" },
6
+ { command: "menu", description: "Show the menu keyboard" },
7
+ { command: "projects", description: "Projects: list / search <q> / new <name>" },
8
+ { command: "sessions", description: "List/resume sessions (active first) \u00b7 /sessions <q>" },
9
+ { command: "active", description: "Sessions running now on the PC" },
10
+ { command: "running", description: "Sessions this chat controls \u2014 switch between them" },
11
+ { command: "killall", description: "Kill all active sessions on the PC" },
12
+ { command: "mcp", description: "Inspect & toggle MCP servers \u00b7 health-check" },
13
+ { command: "tasks", description: "Manage scheduled tasks" },
14
+ { command: "newtask", description: "Create a scheduled task" },
15
+ { command: "history", description: "Show recent conversation history" },
16
+ { command: "new", description: "Start a fresh session here" },
17
+ { command: "status", description: "Current session, project & queue" },
18
+ { command: "usage", description: "Account & context usage" },
19
+ { command: "btw", description: "Queue a follow-up: /btw <text>" },
20
+ { command: "flush", description: "Send queued follow-ups now" },
21
+ { command: "queue", description: "Show queued follow-ups" },
22
+ { command: "cancel", description: "Stop the current turn" },
23
+ { command: "unwatch", description: "Stop following a live session" },
24
+ { command: "model", description: "Switch model: /model <id>" },
25
+ { command: "restart", description: "Restart the Kiro agent" },
26
+ { command: "help", description: "Show help" },
27
+ ];
28
+
29
+ export const HELP_TEXT = [
30
+ "\u{1F916} Kiro Telegram Bot",
31
+ "Drive Kiro CLI from your phone \u2014 projects, resume, live sessions, diffs.",
32
+ "",
33
+ "HOW IT WORKS",
34
+ "\u2022 Just send a message to chat with Kiro in the current project.",
35
+ "\u2022 While Kiro is working, anything you send is queued and runs",
36
+ " automatically when the current turn finishes.",
37
+ "",
38
+ "COMMANDS",
39
+ "/projects \u2014 choose which folder Kiro works in",
40
+ "/sessions \u2014 resume one of your recent Kiro sessions",
41
+ "/active \u2014 attach to a session currently running on the PC",
42
+ "/history \u2014 show the latest messages of the current session",
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",
45
+ "/flush \u2014 run queued follow-ups immediately",
46
+ "/cancel \u2014 stop the current turn",
47
+ "/status \u2014 show session, project and queue size",
48
+ ].join("\n");
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared dependencies passed to all handlers, plus a small per-chat cache for
3
+ * mapping inline-keyboard buttons back to long values (project paths).
4
+ */
5
+ import type { Api } from "grammy";
6
+ import type { AcpClient } from "../acp/client.js";
7
+ import type { SettingsStore } from "../app/settings-store.js";
8
+ import type { AppConfig } from "../config.js";
9
+ import type { SttService } from "../app/stt.js";
10
+ import type { UsageService } from "../app/usage.js";
11
+ import type { ProjectEntry, ProjectManager } from "../projects/manager.js";
12
+ import type { SessionStore } from "../sessions/store.js";
13
+ import type { TaskRunner } from "../tasks/runner.js";
14
+ import type { TaskStore } from "../tasks/store.js";
15
+ import type { StatusPanel } from "./menu/status-panel.js";
16
+ import type { RuntimeRegistry } from "./registry.js";
17
+ import type { TaskWizard } from "./wizard/task-wizard.js";
18
+
19
+ export interface BotDeps {
20
+ api: Api;
21
+ cfg: AppConfig;
22
+ acp: AcpClient;
23
+ registry: RuntimeRegistry;
24
+ store: SessionStore;
25
+ projects: ProjectManager;
26
+ menuCache: MenuCache;
27
+ settings: SettingsStore;
28
+ statusPanel: StatusPanel;
29
+ tasks: TaskStore;
30
+ taskRunner: TaskRunner;
31
+ wizard: TaskWizard;
32
+ stt: SttService;
33
+ usage: UsageService;
34
+ }
35
+
36
+ /** Caches the last project list shown per chat for callback resolution. */
37
+ export class MenuCache {
38
+ private readonly projectLists = new Map<number, ProjectEntry[]>();
39
+
40
+ setProjects(chatId: number, list: ProjectEntry[]): void {
41
+ this.projectLists.set(chatId, list);
42
+ }
43
+
44
+ getProject(chatId: number, index: number): ProjectEntry | undefined {
45
+ return this.projectLists.get(chatId)?.[index];
46
+ }
47
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Control commands: /start /help /status /new /cancel /btw /flush.
3
+ */
4
+ import type { Bot } from "grammy";
5
+ import { basename } from "node:path";
6
+ import { textPrompt } from "../../app/types.js";
7
+ import type { BotDeps } from "../deps.js";
8
+ import { HELP_TEXT } from "../commands.js";
9
+ import { compactKeyboard } from "../menu/keyboard.js";
10
+ import { refreshMenu } from "../menu/refresh.js";
11
+ import { openMainMenu } from "./menu.js";
12
+
13
+ export function registerControl(bot: Bot, deps: BotDeps): void {
14
+ bot.command("start", async (ctx) => {
15
+ const rt = deps.registry.get(ctx.chat.id);
16
+ const agent = deps.acp.agentInfo;
17
+ const lines = [
18
+ "\u{1F44B} Welcome! I bridge Telegram to Kiro CLI over ACP.",
19
+ agent?.name ? `Connected to ${agent.name} ${agent.version ?? ""}`.trim() : "",
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.",
23
+ ].filter(Boolean);
24
+ await ctx.reply(lines.join("\n"), { reply_markup: compactKeyboard() });
25
+ await deps.statusPanel.refresh(ctx.chat.id);
26
+ });
27
+
28
+ bot.command("menu", async (ctx) => {
29
+ await openMainMenu(ctx, deps);
30
+ await deps.statusPanel.refresh(ctx.chat.id);
31
+ });
32
+
33
+ bot.command("help", async (ctx) => {
34
+ await ctx.reply(HELP_TEXT);
35
+ });
36
+
37
+ bot.command("status", async (ctx) => {
38
+ const rt = deps.registry.get(ctx.chat.id);
39
+ const lines = [
40
+ "\u{1F4CA} Status",
41
+ `Project: ${rt.projectName ?? (basename(rt.cwd) || rt.cwd)}`,
42
+ `Folder: ${rt.cwd}`,
43
+ `Session: ${rt.sessionId ?? "(none yet)"}`,
44
+ `State: ${rt.isBusy ? "\u23F3 working" : "\u2705 idle"}`,
45
+ `Queued follow-ups: ${rt.queueLength}`,
46
+ ];
47
+ const subagents = deps.registry.subagentSummaryForChat(ctx.chat.id);
48
+ if (subagents) lines.push(`Subagents: ${subagents}`);
49
+ await ctx.reply(lines.join("\n"));
50
+ });
51
+
52
+ bot.command("new", async (ctx) => {
53
+ const rt = deps.registry.get(ctx.chat.id);
54
+ try {
55
+ await deps.registry.controller(ctx.chat.id).addNew(rt.cwd, rt.projectName);
56
+ await refreshMenu(ctx, deps, `\u2728 New session started in ${rt.projectName ?? rt.cwd}`);
57
+ } catch (err) {
58
+ await ctx.reply(`\u274C Could not start session: ${(err as Error).message}`);
59
+ }
60
+ });
61
+
62
+ bot.command("cancel", async (ctx) => {
63
+ const rt = deps.registry.get(ctx.chat.id);
64
+ const cancelled = await rt.cancel();
65
+ await ctx.reply(cancelled ? "\u23F9 Cancelling current turn\u2026" : "Nothing is running.");
66
+ });
67
+
68
+ bot.command("btw", async (ctx) => {
69
+ const text = (ctx.match || "").toString().trim();
70
+ if (!text) {
71
+ await ctx.reply("Usage: /btw <something to do after the current task>");
72
+ return;
73
+ }
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.`);
77
+ });
78
+
79
+ bot.command("flush", async (ctx) => {
80
+ const rt = deps.registry.get(ctx.chat.id);
81
+ if (rt.queueLength === 0) {
82
+ await ctx.reply("Queue is empty.");
83
+ return;
84
+ }
85
+ if (rt.isBusy) {
86
+ await ctx.reply(`\u23F3 ${rt.queueLength} queued \u2014 they'll run automatically when the current turn ends.`);
87
+ return;
88
+ }
89
+ // Idle: drain the queue by submitting an empty trigger that flushes.
90
+ await ctx.reply("\u25B6\uFE0F Running queued follow-ups\u2026");
91
+ const drained = rt.drainQueueToPrompt();
92
+ if (drained) await rt.submit(drained);
93
+ });
94
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * /history — show the latest messages of the current (or a chosen) session.
3
+ */
4
+ import type { Bot } from "grammy";
5
+ import { basename } from "node:path";
6
+ import type { BotDeps } from "../deps.js";
7
+ import { readHistory } from "../../sessions/history.js";
8
+ import type { SessionMeta } from "../../sessions/types.js";
9
+ import { sendMarkdownDoc } from "../telegram-io.js";
10
+
11
+ const ENTRY_MAX = 700;
12
+ const ROLE_ICON: Record<string, string> = {
13
+ user: "\u{1F464}",
14
+ assistant: "\u{1F916}",
15
+ tool: "\u{1F527}",
16
+ system: "\u2139\uFE0F",
17
+ };
18
+
19
+ export function registerHistory(bot: Bot, deps: BotDeps): void {
20
+ bot.command("history", async (ctx) => {
21
+ const rt = deps.registry.get(ctx.chat.id);
22
+ if (!rt.sessionId) {
23
+ await ctx.reply("No active session. Use /sessions or send a message first.");
24
+ return;
25
+ }
26
+ const meta = deps.store.get(rt.sessionId);
27
+ await showHistory(deps, ctx.chat.id, rt.sessionId, meta);
28
+ });
29
+ }
30
+
31
+ /** Render and send the recent history of a session. */
32
+ export async function showHistory(
33
+ deps: BotDeps,
34
+ chatId: number,
35
+ sessionId: string,
36
+ meta?: SessionMeta,
37
+ count = 16,
38
+ ): Promise<void> {
39
+ const entries = readHistory(deps.store.jsonlPath(sessionId), count);
40
+ if (entries.length === 0) {
41
+ await deps.api.sendMessage(chatId, "No history found for this session yet.");
42
+ return;
43
+ }
44
+ const title = meta?.title || sessionId.slice(0, 8);
45
+ const proj = meta?.cwd ? basename(meta.cwd) : "";
46
+ const header = `\u{1F4DC} **History** \u2014 ${title}${proj ? ` (${proj})` : ""}`;
47
+
48
+ const body = entries
49
+ .map((e) => {
50
+ const icon = ROLE_ICON[e.role] ?? "\u2022";
51
+ let text = e.text.length > ENTRY_MAX ? e.text.slice(0, ENTRY_MAX) + " …" : e.text;
52
+ if (e.role === "tool" && e.tool) text = `\`${e.tool}\` ${text}`;
53
+ return `${icon} ${text}`;
54
+ })
55
+ .join("\n\n");
56
+
57
+ await sendMarkdownDoc(deps.api, chatId, `${header}\n\n${body}`);
58
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * /killall — terminate all active Kiro sessions running on this PC (the ones
3
+ * holding a live session lock), excluding the bot's own agent process. Guarded
4
+ * by an inline confirmation since it kills processes.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
8
+ import { createLogger } from "../../logger.js";
9
+ import type { SessionMeta } from "../../sessions/types.js";
10
+ import type { BotDeps } from "../deps.js";
11
+
12
+ const log = createLogger("killall");
13
+
14
+ function targets(deps: BotDeps): SessionMeta[] {
15
+ const self = deps.acp.pid;
16
+ return deps.store.listActive().filter((s) => s.lockPid && s.lockPid !== self);
17
+ }
18
+
19
+ export async function showKillConfirm(ctx: Context, deps: BotDeps): Promise<void> {
20
+ const active = targets(deps);
21
+ if (active.length === 0) {
22
+ await ctx.reply("\u2705 No other active Kiro sessions to kill.");
23
+ return;
24
+ }
25
+ const list = active
26
+ .slice(0, 12)
27
+ .map((s) => `\u2022 ${s.title.slice(0, 40)} (pid ${s.lockPid})`)
28
+ .join("\n");
29
+ const kb = new InlineKeyboard()
30
+ .text(`\u{1F6D1} Kill ${active.length}`, "killall:confirm")
31
+ .text("Cancel", "killall:cancel");
32
+ await ctx.reply(
33
+ `\u{1F6D1} Kill ${active.length} active session(s)?\n${list}\n\n(The bot's own session is excluded.)`,
34
+ { reply_markup: kb },
35
+ );
36
+ }
37
+
38
+ export function registerKill(bot: Bot, deps: BotDeps): void {
39
+ bot.command("killall", (ctx) => showKillConfirm(ctx, deps));
40
+
41
+ bot.callbackQuery("killall:cancel", async (ctx) => {
42
+ await ctx.answerCallbackQuery();
43
+ await ctx.editMessageText("Cancelled.").catch(() => {});
44
+ });
45
+
46
+ bot.callbackQuery("killall:confirm", async (ctx) => {
47
+ await ctx.answerCallbackQuery();
48
+ const active = targets(deps);
49
+ let killed = 0;
50
+ for (const s of active) {
51
+ if (s.lockPid && killPid(s.lockPid)) killed++;
52
+ }
53
+ await ctx.editMessageText(`\u{1F6D1} Killed ${killed} of ${active.length} active session(s).`).catch(() => {});
54
+ });
55
+ }
56
+
57
+ function killPid(pid: number): boolean {
58
+ try {
59
+ if (process.platform === "win32") {
60
+ execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
61
+ } else {
62
+ process.kill(pid, "SIGKILL");
63
+ }
64
+ return true;
65
+ } catch (e) {
66
+ log.debug(`kill ${pid} failed:`, (e as Error).message);
67
+ return false;
68
+ }
69
+ }