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,83 @@
1
+ /**
2
+ * /projects — browse, search, or create the folder Kiro works in.
3
+ * /projects list all projects
4
+ * /projects <query> list projects whose name contains <query>
5
+ * /projects new <name> create a folder and start a session in it
6
+ * Exposes a reusable project menu used by the menu button and the task wizard.
7
+ */
8
+ import { type Context, InlineKeyboard } from "grammy";
9
+ import type { Bot } from "grammy";
10
+ import type { ProjectEntry } from "../../projects/manager.js";
11
+ import type { BotDeps } from "../deps.js";
12
+ import { refreshMenu } from "../menu/refresh.js";
13
+
14
+ const PAGE = 40;
15
+
16
+ /** Send a project picker. `prefix` is the callback-data prefix (e.g. "proj:"). */
17
+ export async function sendProjectMenu(
18
+ ctx: Context,
19
+ deps: BotDeps,
20
+ prefix: string,
21
+ title: string,
22
+ entries?: ProjectEntry[],
23
+ ): Promise<void> {
24
+ const chatId = ctx.chat!.id;
25
+ const list = entries ?? deps.projects.list(PAGE);
26
+ deps.menuCache.setProjects(chatId, list);
27
+ if (list.length === 0) {
28
+ await ctx.reply("No matching projects. Try `/projects new <name>` to create one.");
29
+ return;
30
+ }
31
+ const kb = new InlineKeyboard();
32
+ list.forEach((p, i) => kb.text(`\u{1F4C1} ${p.name}`, `${prefix}${i}`).row());
33
+ await ctx.reply(title, { reply_markup: kb });
34
+ }
35
+
36
+ export async function showProjects(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
37
+ const arg = (query ?? "").trim();
38
+
39
+ // Create: /projects new <name>
40
+ const create = /^new\s+(.+)$/i.exec(arg);
41
+ if (create) {
42
+ try {
43
+ const entry = deps.projects.create(create[1]!);
44
+ await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
45
+ await refreshMenu(ctx, deps, `\u2705 Created and opened project: ${entry.name}\n${entry.path}`);
46
+ } catch (e) {
47
+ await ctx.reply(`\u274C Could not create project: ${(e as Error).message}`);
48
+ }
49
+ return;
50
+ }
51
+
52
+ // Search: /projects <query>
53
+ if (arg) {
54
+ const found = deps.projects.search(arg, PAGE);
55
+ await sendProjectMenu(ctx, deps, "proj:", `Projects matching "${arg}":`, found);
56
+ return;
57
+ }
58
+
59
+ await sendProjectMenu(ctx, deps, "proj:", "Choose a project:");
60
+ }
61
+
62
+ export function registerProjects(bot: Bot, deps: BotDeps): void {
63
+ bot.command("projects", (ctx) => showProjects(ctx, deps, ctx.match?.toString()));
64
+
65
+ bot.callbackQuery(/^proj:(\d+)$/, async (ctx) => {
66
+ const index = Number(ctx.match![1]);
67
+ const entry = deps.menuCache.getProject(ctx.chat!.id, index);
68
+ if (!entry) {
69
+ await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
70
+ return;
71
+ }
72
+ await ctx.answerCallbackQuery();
73
+ try {
74
+ await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
75
+ await ctx.editMessageText(
76
+ `\u2705 Project set: ${entry.name}\n${entry.path}\n\nNew session ready \u2014 send a message.`,
77
+ );
78
+ await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name}`);
79
+ } catch (err) {
80
+ await ctx.editMessageText(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
81
+ }
82
+ });
83
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * /running — the sessions this chat controls. Tap one to switch to it; on
3
+ * switch you see a header + the target's unread messages (what happened while
4
+ * you were away) or its recent history the first time.
5
+ */
6
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
7
+ import type { SwitchResult } from "../chat-controller.js";
8
+ import type { BotDeps } from "../deps.js";
9
+ import type { HistoryEntry } from "../../sessions/types.js";
10
+ import { refreshMenu } from "../menu/refresh.js";
11
+ import { sendMarkdownDoc } from "../telegram-io.js";
12
+
13
+ const UUID = "([0-9a-fA-F-]{36})";
14
+ const ROLE_ICON: Record<string, string> = {
15
+ user: "\u{1F464}",
16
+ assistant: "\u{1F916}",
17
+ tool: "\u{1F527}",
18
+ system: "\u2139\uFE0F",
19
+ };
20
+ const ENTRY_MAX = 700;
21
+
22
+ function trunc(s: string, n: number): string {
23
+ return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
24
+ }
25
+
26
+ function listView(deps: BotDeps, chatId: number): { text: string; kb: InlineKeyboard } {
27
+ const list = deps.registry.controller(chatId).list();
28
+ const kb = new InlineKeyboard();
29
+ if (list.length === 0) {
30
+ return { text: "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.", kb };
31
+ }
32
+ for (const s of list) {
33
+ const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
34
+ const flags = `${s.busy ? " \u00B7 \u23F3" : ""}${s.unread > 0 ? ` \u00B7 ${s.unread}\u{1F4EC}` : ""}`;
35
+ const label = `${dot} ${trunc(s.projectName, 22)}${flags}`;
36
+ if (!s.sessionId) {
37
+ kb.text(label, "run:noop").row();
38
+ continue;
39
+ }
40
+ kb.text(label, s.foreground ? "run:noop" : `run:switch:${s.sessionId}`).text("\u2716", `run:close:${s.sessionId}`).row();
41
+ }
42
+ return { text: `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap to switch:`, kb };
43
+ }
44
+
45
+ export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
46
+ const { text, kb } = listView(deps, ctx.chat!.id);
47
+ await ctx.reply(text, { reply_markup: kb });
48
+ }
49
+
50
+ /** Switch the chat to a session and show its summary + unread. */
51
+ export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
52
+ const res = await deps.registry.controller(ctx.chat!.id).switchTo(sessionId);
53
+ if (!res) {
54
+ await ctx.reply("Session not found (it may have been closed).");
55
+ return;
56
+ }
57
+ await deliverSwitch(ctx, deps, res);
58
+ }
59
+
60
+ export function registerRunning(bot: Bot, deps: BotDeps): void {
61
+ bot.command("running", (ctx) => showRunning(ctx, deps));
62
+
63
+ bot.callbackQuery("run:noop", (ctx) => ctx.answerCallbackQuery({ text: "Already in foreground" }));
64
+
65
+ bot.callbackQuery(new RegExp(`^run:switch:${UUID}$`), async (ctx) => {
66
+ await ctx.answerCallbackQuery();
67
+ await switchAndShow(ctx, deps, ctx.match![1]!);
68
+ });
69
+
70
+ bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
71
+ await ctx.answerCallbackQuery({ text: "Closed" });
72
+ await deps.registry.controller(ctx.chat!.id).close(ctx.match![1]!);
73
+ const { text, kb } = listView(deps, ctx.chat!.id);
74
+ await ctx.editMessageText(text, { reply_markup: kb }).catch(() => {});
75
+ });
76
+ }
77
+
78
+ async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Promise<void> {
79
+ const proj = res.projectName ?? "session";
80
+ const sid = res.sessionId ? res.sessionId.slice(0, 8) : "?";
81
+ if (res.alreadyForeground) {
82
+ await ctx.reply(`You're already on ${proj} (${sid}).`);
83
+ return;
84
+ }
85
+ const working = res.busy ? " \u00B7 \u23F3 still working (live updates follow)" : "";
86
+ await refreshMenu(ctx, deps, `\u{1F500} Switched to ${proj} (${sid})${working}`);
87
+
88
+ if (res.unread.length === 0) {
89
+ if (!res.busy) await ctx.reply(res.firstView ? "No earlier messages here." : "\u2705 Nothing new while you were away.");
90
+ return;
91
+ }
92
+ const header = res.firstView
93
+ ? `\u{1F4DC} **Recent history** \u2014 ${proj}`
94
+ : `\u{1F4EC} **${res.unread.length} message(s) while away** \u2014 ${proj}`;
95
+ const body = res.unread.map(fmtEntry).join("\n\n");
96
+ await sendMarkdownDoc(deps.api, ctx.chat!.id, `${header}\n\n${body}`);
97
+ }
98
+
99
+ function fmtEntry(e: HistoryEntry): string {
100
+ const icon = ROLE_ICON[e.role] ?? "\u2022";
101
+ if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
102
+ const text = e.text.length > ENTRY_MAX ? e.text.slice(0, ENTRY_MAX) + " \u2026" : e.text;
103
+ return `${icon} ${text}`;
104
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Builds a rich, readable "card" for a single Kiro session: a plain-text body
3
+ * (no MarkdownV2 so Windows paths and titles never need escaping) plus an
4
+ * inline keyboard with Connect / History / Watch actions.
5
+ *
6
+ * Callback data is unchanged (`sess:` / `hist:` / `watch:` + UUID) so the
7
+ * existing handlers in sessions.ts keep working.
8
+ */
9
+ import { InlineKeyboard } from "grammy";
10
+ import { basename } from "node:path";
11
+ import type { SessionMeta } from "../../sessions/types.js";
12
+
13
+ export interface SessionCardExtras {
14
+ /** Context-usage %, when the session is loaded in the current ACP process. */
15
+ contextPct?: number;
16
+ }
17
+
18
+ export interface SessionCard {
19
+ text: string;
20
+ keyboard: InlineKeyboard;
21
+ }
22
+
23
+ /** Build the card body + buttons for one session. */
24
+ export function buildSessionCard(m: SessionMeta, extra: SessionCardExtras = {}): SessionCard {
25
+ const dot = m.active ? "\u{1F7E2}" : "\u26AA";
26
+ const state = m.active ? `running${m.lockPid ? ` \u00B7 pid ${m.lockPid}` : ""}` : "idle";
27
+ const proj = m.cwd ? basename(m.cwd) : "(no project)";
28
+
29
+ const lines = [`${dot} ${m.title}`, `\u{1F4C1} ${proj}`];
30
+ if (m.cwd) lines.push(` ${m.cwd}`);
31
+ lines.push(`\u{1F552} updated ${relTime(m.updatedAt)} \u00B7 created ${relTime(m.createdAt)}`);
32
+ const ctx = typeof extra.contextPct === "number" ? ` \u00B7 \u{1F9E0} ctx ${Math.round(extra.contextPct)}%` : "";
33
+ lines.push(`\u{1F4CA} ${state} \u00B7 \u{1F4DC} history ${humanSize(m.historyBytes)}${ctx}`);
34
+ lines.push(`\u{1F194} ${m.sessionId.slice(0, 8)}`);
35
+
36
+ const connect = m.active ? "\u{1F374} Continue (fork)" : "\u{1F517} Resume";
37
+ const keyboard = new InlineKeyboard()
38
+ .text(connect, `sess:${m.sessionId}`)
39
+ .text("\u{1F4DC} History", `hist:${m.sessionId}`)
40
+ .text("\u{1F4E1} Watch", `watch:${m.sessionId}`);
41
+
42
+ return { text: lines.join("\n"), keyboard };
43
+ }
44
+
45
+ /** Compact relative time, e.g. "42s ago", "5m ago", "3h ago", "2d ago". */
46
+ export function relTime(iso: string): string {
47
+ const t = Date.parse(iso);
48
+ if (!Number.isFinite(t)) return "unknown";
49
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000));
50
+ if (s < 60) return `${s}s ago`;
51
+ const m = Math.floor(s / 60);
52
+ if (m < 60) return `${m}m ago`;
53
+ const h = Math.floor(m / 60);
54
+ if (h < 24) return `${h}h ago`;
55
+ const d = Math.floor(h / 24);
56
+ return `${d}d ago`;
57
+ }
58
+
59
+ /** Human-readable byte size, e.g. "812 B", "42.3 KB", "1.2 MB". */
60
+ export function humanSize(bytes: number): string {
61
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
62
+ if (bytes < 1024) return `${bytes} B`;
63
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
64
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
65
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * /sessions — list recent Kiro sessions and connect to one.
3
+ * /active — list sessions currently running on this PC.
4
+ * /unwatch — stop following a live session.
5
+ *
6
+ * Each session is shown as its own card (status, project + path, times, history
7
+ * size, context %), with Connect (resume, or fork if the session is locked/live),
8
+ * 📜 History (static view), and 📡 Watch (live read-only follow) buttons.
9
+ */
10
+ import { type Bot, type Context } from "grammy";
11
+ import { basename } from "node:path";
12
+ import type { BotDeps } from "../deps.js";
13
+ import { readHistory } from "../../sessions/history.js";
14
+ import type { SessionMeta } from "../../sessions/types.js";
15
+ import { refreshMenu } from "../menu/refresh.js";
16
+ import { showHistory } from "./history.js";
17
+ import { buildSessionCard } from "./session-card.js";
18
+
19
+ /** How many session cards to send at once (avoids flooding the chat). */
20
+ const CARD_LIMIT = 8;
21
+ const UUID = "([0-9a-fA-F-]{36})";
22
+
23
+ export async function showSessions(ctx: Context, deps: BotDeps, query?: string): Promise<void> {
24
+ const q = (query ?? "").trim().toLowerCase();
25
+ let metas = deps.store.list(q ? 200 : 50);
26
+ if (q) {
27
+ metas = metas.filter((m) => `${m.title} ${m.cwd} ${m.sessionId}`.toLowerCase().includes(q));
28
+ }
29
+ if (metas.length === 0) {
30
+ await ctx.reply(q ? `No sessions match "${q}".` : "No saved sessions found in ~/.kiro/sessions/cli.");
31
+ return;
32
+ }
33
+ await sendSessionCards(ctx, deps, metas, q ? `Sessions matching "${q}"` : "Recent sessions");
34
+ }
35
+
36
+ /** Send a header then one rich card per session (active first, capped). */
37
+ async function sendSessionCards(
38
+ ctx: Context,
39
+ deps: BotDeps,
40
+ metas: SessionMeta[],
41
+ heading: string,
42
+ ): Promise<void> {
43
+ const shown = metas.slice(0, CARD_LIMIT);
44
+ const live = shown.filter((m) => m.active).length;
45
+ const ofTotal = metas.length > shown.length ? ` of ${metas.length}` : "";
46
+ const liveStr = live ? ` \u00B7 \u{1F7E2} ${live} live` : "";
47
+ await ctx.reply(`\u{1F5C2} ${heading} \u2014 ${shown.length} shown${ofTotal}${liveStr}`);
48
+
49
+ for (const m of shown) {
50
+ const contextPct = deps.acp.metadataFor(m.sessionId)?.contextUsagePercentage;
51
+ const { text, keyboard } = buildSessionCard(m, { contextPct });
52
+ await ctx.reply(text, { reply_markup: keyboard });
53
+ }
54
+
55
+ if (metas.length > shown.length) {
56
+ await ctx.reply(`\u2026and ${metas.length - shown.length} more. Use /sessions <query> to filter.`);
57
+ }
58
+ }
59
+
60
+ export function registerSessions(bot: Bot, deps: BotDeps): void {
61
+ bot.command("sessions", (ctx) => showSessions(ctx, deps, ctx.match?.toString()));
62
+
63
+ bot.command("active", async (ctx) => {
64
+ const metas = deps.store.listActive();
65
+ if (metas.length === 0) {
66
+ await ctx.reply("No sessions are currently running on this PC.");
67
+ return;
68
+ }
69
+ await sendSessionCards(ctx, deps, metas, "Live sessions running now");
70
+ });
71
+
72
+ bot.command("unwatch", async (ctx) => {
73
+ const rt = deps.registry.get(ctx.chat.id);
74
+ await ctx.reply(rt.stopWatch() ? "\u{1F6D1} Stopped watching." : "Not watching anything.");
75
+ });
76
+
77
+ bot.callbackQuery(new RegExp(`^sess:${UUID}$`), async (ctx) => {
78
+ const id = ctx.match![1]!;
79
+ const meta = deps.store.get(id);
80
+ if (!meta) {
81
+ await ctx.answerCallbackQuery({ text: "Session not found." });
82
+ return;
83
+ }
84
+ await ctx.answerCallbackQuery();
85
+ const fgCwd = deps.registry.get(ctx.chat!.id).cwd;
86
+ const cwd = meta.cwd || fgCwd;
87
+ const projectName = basename(meta.cwd || fgCwd) || "session";
88
+ const prior = readHistory(deps.store.jsonlPath(id), 24);
89
+ try {
90
+ const { result, alreadyControlled } = await deps.registry
91
+ .controller(ctx.chat!.id)
92
+ .addAttach(id, cwd, projectName, prior);
93
+ await ctx.editMessageText(alreadyControlled ? `\u{1F500} Switched to ${meta.title}` : connectMessage(result, meta));
94
+ await refreshMenu(ctx, deps, `\u{1F4C2} ${meta.title}`);
95
+ await showHistory(deps, ctx.chat!.id, id, meta);
96
+ } catch (err) {
97
+ await ctx.editMessageText(`\u274C Could not connect: ${(err as Error).message}`);
98
+ }
99
+ });
100
+
101
+ bot.callbackQuery(new RegExp(`^hist:${UUID}$`), async (ctx) => {
102
+ const id = ctx.match![1]!;
103
+ await ctx.answerCallbackQuery();
104
+ const meta = deps.store.get(id);
105
+ await showHistory(deps, ctx.chat!.id, id, meta);
106
+ });
107
+
108
+ bot.callbackQuery(new RegExp(`^watch:${UUID}$`), async (ctx) => {
109
+ const id = ctx.match![1]!;
110
+ await ctx.answerCallbackQuery();
111
+ const meta = deps.store.get(id);
112
+ const rt = deps.registry.get(ctx.chat!.id);
113
+ rt.startWatch(deps.store.jsonlPath(id));
114
+ await ctx.reply(
115
+ `\u{1F4E1} Watching live: ${meta?.title ?? id.slice(0, 8)}\nNew activity streams here. Send /unwatch to stop.`,
116
+ );
117
+ });
118
+ }
119
+
120
+ function connectMessage(result: "resumed" | "forked", meta: SessionMeta): string {
121
+ if (result === "resumed") {
122
+ return `\u2705 Resumed: ${meta.title}\n${meta.cwd}\n\nSend a message to continue.`;
123
+ }
124
+ return [
125
+ `\u26A0\uFE0F ${meta.title} is live on your PC right now, so Kiro keeps it locked.`,
126
+ `I opened a linked continuation here in the same project with its recent context.`,
127
+ `${meta.cwd}`,
128
+ ``,
129
+ `Send a message to keep going \u2014 or tap \u{1F4E1} to watch the original live.`,
130
+ ].join("\n");
131
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * System commands: /queue /clearqueue /model /restart.
3
+ */
4
+ import type { Bot } from "grammy";
5
+ import type { BotDeps } from "../deps.js";
6
+
7
+ export function registerSystem(bot: Bot, deps: BotDeps): void {
8
+ bot.command("queue", async (ctx) => {
9
+ const rt = deps.registry.get(ctx.chat.id);
10
+ if (rt.queueLength === 0) {
11
+ await ctx.reply("Queue is empty. Send a message while I'm busy, or use /btw <text>.");
12
+ return;
13
+ }
14
+ await ctx.reply(`\u{1F4E5} ${rt.queueLength} follow-up(s) queued. They run automatically after the current turn, or use /flush.`);
15
+ });
16
+
17
+ bot.command("clearqueue", async (ctx) => {
18
+ const rt = deps.registry.get(ctx.chat.id);
19
+ const n = rt.clearQueue();
20
+ await ctx.reply(n > 0 ? `\u{1F5D1} Cleared ${n} queued message(s).` : "Queue was already empty.");
21
+ });
22
+
23
+ bot.command("model", async (ctx) => {
24
+ const modelId = (ctx.match || "").toString().trim();
25
+ const rt = deps.registry.get(ctx.chat.id);
26
+ if (!modelId) {
27
+ await ctx.reply("Usage: /model <model-id> (changes the model for the current session)");
28
+ return;
29
+ }
30
+ if (!rt.sessionId) {
31
+ await ctx.reply("No active session yet. Send a message or pick a /projects folder first.");
32
+ return;
33
+ }
34
+ try {
35
+ await deps.acp.setModel(rt.sessionId, modelId);
36
+ await ctx.reply(`\u2705 Model set to \`${modelId}\` for this session.`, { parse_mode: "Markdown" });
37
+ } catch (err) {
38
+ await ctx.reply(`\u274C Could not set model: ${(err as Error).message}`);
39
+ }
40
+ });
41
+
42
+ bot.command("restart", async (ctx) => {
43
+ await ctx.reply("\u{1F501} Restarting the Kiro agent\u2026");
44
+ try {
45
+ await deps.acp.restart();
46
+ await ctx.reply("\u2705 Kiro agent restarted. Your session will re-bind on the next message.");
47
+ } catch (err) {
48
+ await ctx.reply(`\u274C Restart failed: ${(err as Error).message}`);
49
+ }
50
+ });
51
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * /tasks — manage scheduled tasks (create, list, view, edit, delete, run now).
3
+ * /newtask — start the creation wizard.
4
+ *
5
+ * A task is a prompt + a project + a schedule (once/daily/weekly/monthly/
6
+ * interval). The scheduler runs it and delivers the result here.
7
+ */
8
+ import { type Bot, type Context, InlineKeyboard } from "grammy";
9
+ import { basename } from "node:path";
10
+ import type { BotDeps } from "../deps.js";
11
+ import { describeSchedule } from "../../tasks/schedule.js";
12
+ import type { Task } from "../../tasks/types.js";
13
+ import type { ScheduleType } from "../../tasks/types.js";
14
+ import type { WizardPrompt } from "../wizard/task-wizard.js";
15
+ import { sendProjectMenu } from "./projects.js";
16
+
17
+ const UUID = "([0-9a-fA-F-]{36})";
18
+
19
+ export async function showTasks(ctx: Context, deps: BotDeps): Promise<void> {
20
+ const { text, kb } = listView(deps, ctx.chat!.id);
21
+ await ctx.reply(text, { reply_markup: kb });
22
+ }
23
+
24
+ export async function renderWizardPrompt(ctx: Context, deps: BotDeps, p: WizardPrompt): Promise<void> {
25
+ switch (p.kind) {
26
+ case "text":
27
+ await ctx.reply(p.text);
28
+ return;
29
+ case "project":
30
+ await sendProjectMenu(ctx, deps, "wiz:proj:", p.text);
31
+ return;
32
+ case "scheduleType": {
33
+ const kb = new InlineKeyboard()
34
+ .text("Once", "wiz:sched:once")
35
+ .text("Daily", "wiz:sched:daily")
36
+ .row()
37
+ .text("Weekly", "wiz:sched:weekly")
38
+ .text("Monthly", "wiz:sched:monthly")
39
+ .row()
40
+ .text("Every N minutes", "wiz:sched:interval");
41
+ await ctx.reply(p.text, { reply_markup: kb });
42
+ return;
43
+ }
44
+ case "confirm": {
45
+ const kb = new InlineKeyboard().text("\u2705 Save", "wiz:confirm").text("\u2716 Cancel", "wiz:cancel");
46
+ await ctx.reply(p.text, { reply_markup: kb });
47
+ return;
48
+ }
49
+ case "done":
50
+ await ctx.reply(p.text);
51
+ return;
52
+ case "aborted":
53
+ await ctx.reply("Cancelled.");
54
+ return;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Wizard text-input interceptor. Registered BEFORE command/prompt handlers so
60
+ * that, while a task wizard is active, free text feeds the wizard. A slash
61
+ * command aborts the wizard and is allowed through.
62
+ */
63
+ export function registerWizardInput(bot: Bot, deps: BotDeps): void {
64
+ bot.on("message:text", async (ctx, next) => {
65
+ const chatId = ctx.chat.id;
66
+ if (!deps.wizard.isActive(chatId)) return next();
67
+ const text = ctx.message.text;
68
+ if (text.startsWith("/")) {
69
+ deps.wizard.abort(chatId);
70
+ return next();
71
+ }
72
+ const p = deps.wizard.handleText(chatId, text);
73
+ if (p) await renderWizardPrompt(ctx, deps, p);
74
+ });
75
+ }
76
+
77
+ export function registerTasks(bot: Bot, deps: BotDeps): void {
78
+ bot.command("tasks", (ctx) => showTasks(ctx, deps));
79
+ bot.command("newtask", async (ctx) => {
80
+ await renderWizardPrompt(ctx, deps, deps.wizard.startCreate(ctx.chat.id));
81
+ });
82
+
83
+ bot.callbackQuery("task:new", async (ctx) => {
84
+ await ctx.answerCallbackQuery();
85
+ await renderWizardPrompt(ctx, deps, deps.wizard.startCreate(ctx.chat!.id));
86
+ });
87
+
88
+ bot.callbackQuery(new RegExp(`^task:view:${UUID}$`), async (ctx) => {
89
+ await ctx.answerCallbackQuery();
90
+ const task = deps.tasks.get(ctx.match![1]!);
91
+ if (!task) return void ctx.editMessageText("Task not found.");
92
+ const { text, kb } = detailView(task);
93
+ await ctx.editMessageText(text, { reply_markup: kb });
94
+ });
95
+
96
+ bot.callbackQuery("task:list", async (ctx) => {
97
+ await ctx.answerCallbackQuery();
98
+ const { text, kb } = listView(deps, ctx.chat!.id);
99
+ await ctx.editMessageText(text, { reply_markup: kb });
100
+ });
101
+
102
+ bot.callbackQuery(new RegExp(`^task:toggle:${UUID}$`), async (ctx) => {
103
+ const task = deps.tasks.get(ctx.match![1]!);
104
+ if (!task) return void ctx.answerCallbackQuery({ text: "Not found" });
105
+ const updated = deps.tasks.update(task.id, { enabled: !task.enabled });
106
+ await ctx.answerCallbackQuery({ text: updated?.enabled ? "Enabled" : "Disabled" });
107
+ if (updated) {
108
+ const { text, kb } = detailView(updated);
109
+ await ctx.editMessageText(text, { reply_markup: kb });
110
+ }
111
+ });
112
+
113
+ bot.callbackQuery(new RegExp(`^task:run:${UUID}$`), async (ctx) => {
114
+ const task = deps.tasks.get(ctx.match![1]!);
115
+ if (!task) return void ctx.answerCallbackQuery({ text: "Not found" });
116
+ await ctx.answerCallbackQuery({ text: "Running now\u2026" });
117
+ void deps.taskRunner.run(task);
118
+ });
119
+
120
+ bot.callbackQuery(new RegExp(`^task:del:${UUID}$`), async (ctx) => {
121
+ deps.tasks.delete(ctx.match![1]!);
122
+ await ctx.answerCallbackQuery({ text: "Deleted" });
123
+ const { text, kb } = listView(deps, ctx.chat!.id);
124
+ await ctx.editMessageText(text, { reply_markup: kb });
125
+ });
126
+
127
+ bot.callbackQuery(new RegExp(`^task:editmenu:${UUID}$`), async (ctx) => {
128
+ await ctx.answerCallbackQuery();
129
+ const task = deps.tasks.get(ctx.match![1]!);
130
+ if (!task) return;
131
+ await ctx.editMessageText(`Edit "${task.name}" — choose what to change:`, {
132
+ reply_markup: editMenu(task.id),
133
+ });
134
+ });
135
+
136
+ bot.callbackQuery(new RegExp(`^task:edit:(name|prompt|project|schedule):${UUID}$`), async (ctx) => {
137
+ await ctx.answerCallbackQuery();
138
+ const field = ctx.match![1] as "name" | "prompt" | "project" | "schedule";
139
+ const p = deps.wizard.startEdit(ctx.chat!.id, ctx.match![2]!, field);
140
+ if (p) await renderWizardPrompt(ctx, deps, p);
141
+ else await ctx.reply("Task not found.");
142
+ });
143
+
144
+ // ── wizard inline steps ────────────────────────────────────────────────
145
+ bot.callbackQuery(/^wiz:proj:(\d+)$/, async (ctx) => {
146
+ const entry = deps.menuCache.getProject(ctx.chat!.id, Number(ctx.match![1]));
147
+ if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, restart the task." });
148
+ await ctx.answerCallbackQuery();
149
+ const p = deps.wizard.setProject(ctx.chat!.id, entry.path, entry.name);
150
+ if (p) await renderWizardPrompt(ctx, deps, p);
151
+ });
152
+
153
+ bot.callbackQuery(/^wiz:sched:(once|daily|weekly|monthly|interval)$/, async (ctx) => {
154
+ await ctx.answerCallbackQuery();
155
+ const p = deps.wizard.setScheduleType(ctx.chat!.id, ctx.match![1] as ScheduleType);
156
+ if (p) await renderWizardPrompt(ctx, deps, p);
157
+ });
158
+
159
+ bot.callbackQuery("wiz:confirm", async (ctx) => {
160
+ await ctx.answerCallbackQuery();
161
+ const p = deps.wizard.confirm(ctx.chat!.id);
162
+ if (p) await renderWizardPrompt(ctx, deps, p);
163
+ });
164
+
165
+ bot.callbackQuery("wiz:cancel", async (ctx) => {
166
+ await ctx.answerCallbackQuery();
167
+ deps.wizard.abort(ctx.chat!.id);
168
+ await ctx.editMessageText("Cancelled.");
169
+ });
170
+ }
171
+
172
+ // ── views ────────────────────────────────────────────────────────────────
173
+
174
+ function listView(deps: BotDeps, chatId: number): { text: string; kb: InlineKeyboard } {
175
+ const tasks = deps.tasks.forChat(chatId);
176
+ const kb = new InlineKeyboard();
177
+ if (tasks.length === 0) {
178
+ kb.text("\u2795 New task", "task:new");
179
+ return { text: "You have no scheduled tasks yet.", kb };
180
+ }
181
+ for (const t of tasks) {
182
+ const dot = t.enabled ? "\u{1F7E2}" : "\u26AA";
183
+ const name = t.name.length > 24 ? t.name.slice(0, 24) + "\u2026" : t.name;
184
+ kb.text(`${dot} ${name} \u00B7 ${describeSchedule(t.schedule)}`, `task:view:${t.id}`).row();
185
+ }
186
+ kb.text("\u2795 New task", "task:new");
187
+ return { text: `\u{1F5D3} Your scheduled tasks (${tasks.length}):`, kb };
188
+ }
189
+
190
+ function detailView(t: Task): { text: string; kb: InlineKeyboard } {
191
+ const next = t.nextRun ? new Date(t.nextRun).toLocaleString() : "\u2014";
192
+ const last = t.lastRun ? `${new Date(t.lastRun).toLocaleString()} (${t.lastStatus ?? "?"})` : "never";
193
+ const prompt = t.prompt.length > 300 ? t.prompt.slice(0, 300) + "\u2026" : t.prompt;
194
+ const text = [
195
+ `\u{1F5D3} ${t.name} ${t.enabled ? "\u{1F7E2} enabled" : "\u26AA disabled"}`,
196
+ `\u{1F4C1} Project: ${t.projectName || basename(t.projectPath)}`,
197
+ `\u{1F501} Schedule: ${describeSchedule(t.schedule)}`,
198
+ `\u23ED Next run: ${next}`,
199
+ `\u23EE Last run: ${last}`,
200
+ "",
201
+ `\u{1F4AC} ${prompt}`,
202
+ ].join("\n");
203
+ const kb = new InlineKeyboard()
204
+ .text("\u25B6 Run now", `task:run:${t.id}`)
205
+ .text(t.enabled ? "\u23F8 Disable" : "\u25B6 Enable", `task:toggle:${t.id}`)
206
+ .row()
207
+ .text("\u270F\uFE0F Edit", `task:editmenu:${t.id}`)
208
+ .text("\u{1F5D1} Delete", `task:del:${t.id}`)
209
+ .row()
210
+ .text("\u2B05 Back", "task:list");
211
+ return { text, kb };
212
+ }
213
+
214
+ function editMenu(id: string): InlineKeyboard {
215
+ return new InlineKeyboard()
216
+ .text("Rename", `task:edit:name:${id}`)
217
+ .text("Prompt", `task:edit:prompt:${id}`)
218
+ .row()
219
+ .text("Project", `task:edit:project:${id}`)
220
+ .text("Schedule", `task:edit:schedule:${id}`)
221
+ .row()
222
+ .text("\u2B05 Back", `task:view:${id}`);
223
+ }