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.
- package/.env.example +104 -0
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/bin/kiro-tg.mjs +21 -0
- package/docs/INSTALL.md +143 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +70 -0
- package/scripts/mq.ts +25 -0
- package/scripts/setup.mjs +78 -0
- package/src/acp/client.ts +456 -0
- package/src/acp/server-handlers.ts +85 -0
- package/src/acp/transport.ts +50 -0
- package/src/acp/types.ts +136 -0
- package/src/agents/catalog.ts +44 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +31 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +48 -0
- package/src/app/usage.ts +32 -0
- package/src/bot/auth.ts +27 -0
- package/src/bot/bot.ts +154 -0
- package/src/bot/chat-controller.ts +251 -0
- package/src/bot/commands.ts +48 -0
- package/src/bot/deps.ts +47 -0
- package/src/bot/handlers/control.ts +94 -0
- package/src/bot/handlers/history.ts +58 -0
- package/src/bot/handlers/kill.ts +69 -0
- package/src/bot/handlers/mcp.ts +205 -0
- package/src/bot/handlers/menu.ts +204 -0
- package/src/bot/handlers/message.ts +93 -0
- package/src/bot/handlers/photo.ts +108 -0
- package/src/bot/handlers/projects.ts +83 -0
- package/src/bot/handlers/running.ts +104 -0
- package/src/bot/handlers/session-card.ts +65 -0
- package/src/bot/handlers/sessions.ts +131 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +223 -0
- package/src/bot/handlers/usage.ts +33 -0
- package/src/bot/handlers/voice.ts +53 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/keyboard.ts +47 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +78 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +49 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/registry.ts +178 -0
- package/src/bot/session-runtime.ts +670 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +125 -0
- package/src/config.ts +190 -0
- package/src/index.ts +74 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +103 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +88 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/markdown.ts +126 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +102 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +83 -0
- package/src/service/macos.ts +91 -0
- package/src/service/platform.ts +59 -0
- package/src/service/types.ts +34 -0
- package/src/service/windows.ts +103 -0
- package/src/sessions/history.ts +181 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +167 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /usage — show account info and the current session's context usage.
|
|
3
|
+
*/
|
|
4
|
+
import type { Bot, Context } from "grammy";
|
|
5
|
+
import type { BotDeps } from "../deps.js";
|
|
6
|
+
|
|
7
|
+
export async function showUsage(ctx: Context, deps: BotDeps): Promise<void> {
|
|
8
|
+
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
9
|
+
const rt = deps.registry.get(ctx.chat!.id);
|
|
10
|
+
const acct = await deps.usage.account();
|
|
11
|
+
const meta = rt.contextInfo();
|
|
12
|
+
const ctx100 = meta?.contextUsagePercentage;
|
|
13
|
+
|
|
14
|
+
const lines = [
|
|
15
|
+
"\u{1F4CA} Usage & account",
|
|
16
|
+
acct?.email ? `\u{1F464} ${acct.email}` : "",
|
|
17
|
+
acct?.accountType ? `\u{1F511} ${acct.accountType}${acct.region ? ` \u00B7 ${acct.region}` : ""}` : "",
|
|
18
|
+
"",
|
|
19
|
+
`\u{1F9F5} Session: ${rt.sessionId ? rt.sessionId.slice(0, 8) : "none"}`,
|
|
20
|
+
`\u{1F9E9} Model: ${rt.model || "default"}`,
|
|
21
|
+
`\u{1F4CA} Context used: ${ctx100 !== undefined ? `${ctx100.toFixed(0)}%` : "\u2014"}`,
|
|
22
|
+
meta?.effort ? `\u{1F9E0} Effort: ${meta.effort}` : "",
|
|
23
|
+
"",
|
|
24
|
+
"\u2139\uFE0F Full billing/quota lives in the Kiro app, or run /usage inside `kiro-cli chat`.",
|
|
25
|
+
].filter(Boolean);
|
|
26
|
+
|
|
27
|
+
if (!acct) lines.splice(1, 0, "(account info unavailable \u2014 is kiro-cli logged in?)");
|
|
28
|
+
await ctx.reply(lines.join("\n"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerUsage(bot: Bot, deps: BotDeps): void {
|
|
32
|
+
bot.command("usage", (ctx) => showUsage(ctx, deps));
|
|
33
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voice & audio handler — transcribes Telegram voice notes / audio files to
|
|
3
|
+
* text (any language) and submits them as prompts.
|
|
4
|
+
*/
|
|
5
|
+
import type { Bot, Context } from "grammy";
|
|
6
|
+
import { textPrompt } from "../../app/types.js";
|
|
7
|
+
import { createLogger } from "../../logger.js";
|
|
8
|
+
import type { BotDeps } from "../deps.js";
|
|
9
|
+
|
|
10
|
+
const log = createLogger("voice");
|
|
11
|
+
|
|
12
|
+
export function registerVoice(bot: Bot, deps: BotDeps): void {
|
|
13
|
+
const handle = async (ctx: Context, fileId: string, mime: string, name: string): Promise<void> => {
|
|
14
|
+
const chatId = ctx.chat!.id;
|
|
15
|
+
if (deps.wizard.isActive(chatId)) {
|
|
16
|
+
await ctx.reply("Finish or /cancel the task wizard before sending voice.");
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (!deps.stt.enabled) {
|
|
20
|
+
await ctx.reply("\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY) in .env.");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await ctx.replyWithChatAction("typing").catch(() => {});
|
|
24
|
+
try {
|
|
25
|
+
const bytes = await download(ctx, fileId, deps.cfg.token);
|
|
26
|
+
if (!bytes) throw new Error("could not download the audio");
|
|
27
|
+
const text = await deps.stt.transcribe(bytes, mime, name);
|
|
28
|
+
if (!text) {
|
|
29
|
+
await ctx.reply("\u{1F399} I couldn't make out any speech.");
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
await ctx.reply(`\u{1F399} \u201C${text}\u201D`);
|
|
33
|
+
const rt = deps.registry.get(chatId);
|
|
34
|
+
const outcome = await rt.submit(textPrompt(text));
|
|
35
|
+
if (outcome === "queued") await ctx.reply("\u{1F4E5} Queued \u2014 will run after the current task.");
|
|
36
|
+
} catch (e) {
|
|
37
|
+
log.warn("voice failed:", (e as Error).message);
|
|
38
|
+
await ctx.reply(`\u274C Voice transcription failed: ${(e as Error).message}`);
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
bot.on("message:voice", (ctx) => handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"));
|
|
43
|
+
bot.on("message:audio", (ctx) => handle(ctx, ctx.message.audio.file_id, ctx.message.audio.mime_type || "audio/mpeg", ctx.message.audio.file_name || "audio.mp3"));
|
|
44
|
+
bot.on("message:video_note", (ctx) => handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
|
|
48
|
+
const file = await ctx.api.getFile(fileId);
|
|
49
|
+
if (!file.file_path) return undefined;
|
|
50
|
+
const res = await fetch(`https://api.telegram.org/file/bot${token}/${file.file_path}`);
|
|
51
|
+
if (!res.ok) throw new Error(`download HTTP ${res.status}`);
|
|
52
|
+
return Buffer.from(await res.arrayBuffer());
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent image return — detects image files the agent produced this turn
|
|
3
|
+
* (screenshots, diagrams…) from its output and tool inputs, and sends them
|
|
4
|
+
* back to Telegram. Only fresh files (modified during the turn) are sent.
|
|
5
|
+
*/
|
|
6
|
+
import { type Api, InputFile } from "grammy";
|
|
7
|
+
import { existsSync, statSync } from "node:fs";
|
|
8
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
9
|
+
import { createLogger } from "../logger.js";
|
|
10
|
+
|
|
11
|
+
const log = createLogger("image-return");
|
|
12
|
+
|
|
13
|
+
const PATH_RE = /[^\s"'`<>|()*\[\]]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
|
|
14
|
+
const PHOTO_EXT = new Set(["png", "jpg", "jpeg", "webp"]);
|
|
15
|
+
const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
|
|
16
|
+
const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
/** Pull candidate image paths out of arbitrary text, resolved against cwd. */
|
|
19
|
+
export function extractImagePaths(text: string, cwd: string): string[] {
|
|
20
|
+
const out = new Set<string>();
|
|
21
|
+
for (const m of text.matchAll(PATH_RE)) {
|
|
22
|
+
const raw = m[0].replace(/[).,;:]+$/, "");
|
|
23
|
+
out.add(isAbsolute(raw) ? raw : join(cwd, raw));
|
|
24
|
+
}
|
|
25
|
+
return [...out];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SendImagesOptions {
|
|
29
|
+
/** Only send files modified at/after this epoch ms (fresh this turn). */
|
|
30
|
+
since: number;
|
|
31
|
+
/** Paths already sent (mutated to dedupe). */
|
|
32
|
+
already: Set<string>;
|
|
33
|
+
/** Max images to send in this call. */
|
|
34
|
+
max: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
|
|
38
|
+
export async function sendImages(
|
|
39
|
+
api: Api,
|
|
40
|
+
chatId: number,
|
|
41
|
+
paths: string[],
|
|
42
|
+
opts: SendImagesOptions,
|
|
43
|
+
): Promise<number> {
|
|
44
|
+
let sent = 0;
|
|
45
|
+
for (const path of paths) {
|
|
46
|
+
if (sent >= opts.max) break;
|
|
47
|
+
if (opts.already.has(path)) continue;
|
|
48
|
+
let st: ReturnType<typeof statSync>;
|
|
49
|
+
try {
|
|
50
|
+
st = statSync(path);
|
|
51
|
+
} catch {
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
|
|
55
|
+
if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
|
|
56
|
+
opts.already.add(path);
|
|
57
|
+
try {
|
|
58
|
+
const ext = path.toLowerCase().split(".").pop() ?? "";
|
|
59
|
+
const asPhoto = PHOTO_EXT.has(ext) && st.size <= MAX_PHOTO_BYTES;
|
|
60
|
+
const file = new InputFile(path);
|
|
61
|
+
if (asPhoto) await api.sendPhoto(chatId, file, { caption: basename(path) });
|
|
62
|
+
else await api.sendDocument(chatId, file, { caption: basename(path) });
|
|
63
|
+
sent++;
|
|
64
|
+
} catch (e) {
|
|
65
|
+
log.debug(`failed to send ${path}:`, (e as Error).message);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return sent;
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu surfaces:
|
|
3
|
+
* - a tiny PERSISTENT bar (☰ Menu · 🧭 Running · ⏹ Stop) — minimal footprint;
|
|
4
|
+
* - a full, organized INLINE menu opened on demand (and hideable).
|
|
5
|
+
* Live state (project/agent/model/reasoning/context) lives in the pinned panel,
|
|
6
|
+
* so the bar stays clean.
|
|
7
|
+
*/
|
|
8
|
+
import { InlineKeyboard, Keyboard } from "grammy";
|
|
9
|
+
|
|
10
|
+
export const MENU_BTN = "\u2630 Menu"; // ☰
|
|
11
|
+
export const RUNNING_BTN = "\u{1F9ED} Running";
|
|
12
|
+
export const STOP_BTN = "\u23F9 Stop";
|
|
13
|
+
export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
|
|
14
|
+
|
|
15
|
+
/** The always-visible compact bar. */
|
|
16
|
+
export function compactKeyboard(): Keyboard {
|
|
17
|
+
return new Keyboard().text(MENU_BTN).text(RUNNING_BTN).text(STOP_BTN).resized().persistent();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The full, grouped inline menu (opened via ☰ Menu or /menu). */
|
|
21
|
+
export function mainMenuInline(state: { agent: string; model: string; reasoning: string }): InlineKeyboard {
|
|
22
|
+
const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
|
|
23
|
+
return new InlineKeyboard()
|
|
24
|
+
.text("\u{1F4C1} Project", "m:project")
|
|
25
|
+
.text("\u{1F195} New", "m:new")
|
|
26
|
+
.row()
|
|
27
|
+
.text("\u{1F9ED} Running", "m:running")
|
|
28
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
29
|
+
.row()
|
|
30
|
+
.text(`\u{1F916} Agent \u00B7 ${t(state.agent, 24)}`, "m:agent")
|
|
31
|
+
.row()
|
|
32
|
+
.text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
|
|
33
|
+
.row()
|
|
34
|
+
.text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
|
|
35
|
+
.row()
|
|
36
|
+
.text("\u2705 Tasks", "m:tasks")
|
|
37
|
+
.text("\u{1F4CA} Status", "m:status")
|
|
38
|
+
.text("\u{1F4B3} Usage", "m:usage")
|
|
39
|
+
.row()
|
|
40
|
+
.text("\u{1F9E9} MCP", "m:mcp")
|
|
41
|
+
.text("\u23F9 Stop", "m:stop")
|
|
42
|
+
.text("\u{1F6D1} Kill all", "m:killall")
|
|
43
|
+
.row()
|
|
44
|
+
.text("\u2328\uFE0F Show bar", "m:showbar")
|
|
45
|
+
.text("\u{1F648} Hide bar", "m:hidebar")
|
|
46
|
+
.text("\u2716 Close", "m:close");
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends a short message that (re)shows the compact bar and refreshes the pinned
|
|
3
|
+
* status panel (where the live project/agent/model/reasoning state is shown).
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from "grammy";
|
|
6
|
+
import type { BotDeps } from "../deps.js";
|
|
7
|
+
import { compactKeyboard } from "./keyboard.js";
|
|
8
|
+
|
|
9
|
+
export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
10
|
+
const chatId = ctx.chat!.id;
|
|
11
|
+
await ctx.reply(text, { reply_markup: compactKeyboard() });
|
|
12
|
+
await deps.statusPanel.refresh(chatId);
|
|
13
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status panel — a pinned message that always shows the current project,
|
|
3
|
+
* agent, reasoning effort, model, session and activity. Updated whenever the
|
|
4
|
+
* runtime's state changes. The pinned message id is persisted per chat.
|
|
5
|
+
*/
|
|
6
|
+
import { type Api, GrammyError } from "grammy";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { reasoningLabel } from "../../app/reasoning.js";
|
|
9
|
+
import type { SettingsStore } from "../../app/settings-store.js";
|
|
10
|
+
import { createLogger } from "../../logger.js";
|
|
11
|
+
import type { RuntimeRegistry } from "../registry.js";
|
|
12
|
+
|
|
13
|
+
const log = createLogger("status-panel");
|
|
14
|
+
|
|
15
|
+
export class StatusPanel {
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly api: Api,
|
|
18
|
+
private readonly settings: SettingsStore,
|
|
19
|
+
private readonly registry: RuntimeRegistry,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/** Build the status text from settings + live runtime state. */
|
|
23
|
+
render(chatId: number): string {
|
|
24
|
+
const s = this.settings.get(chatId);
|
|
25
|
+
const rt = this.registry.get(chatId);
|
|
26
|
+
// Project comes from the live foreground runtime — not the persisted single
|
|
27
|
+
// session — so it always matches the session id shown below, even right
|
|
28
|
+
// after switching between controlled sessions in different projects.
|
|
29
|
+
const project = rt.projectName || (rt.cwd ? basename(rt.cwd) : "(none)");
|
|
30
|
+
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
|
+
const meta = rt.contextInfo();
|
|
34
|
+
const ctx = meta?.contextUsagePercentage !== undefined ? `${meta.contextUsagePercentage.toFixed(0)}%` : "\u2014";
|
|
35
|
+
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
|
+
const subagents = this.registry.subagentSummaryForChat(chatId);
|
|
48
|
+
if (subagents) lines.push(`\u{1F465} Subagents: ${subagents}`);
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Refresh (or create + pin) the status message for a chat. */
|
|
53
|
+
async refresh(chatId: number): Promise<void> {
|
|
54
|
+
const text = this.render(chatId);
|
|
55
|
+
const id = this.settings.get(chatId).statusMessageId;
|
|
56
|
+
|
|
57
|
+
if (id) {
|
|
58
|
+
try {
|
|
59
|
+
await this.api.editMessageText(chatId, id, text);
|
|
60
|
+
return;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err instanceof GrammyError && /not modified/i.test(err.description)) return;
|
|
63
|
+
log.debug("status edit failed, recreating:", (err as Error).message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await this.create(chatId, text);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async create(chatId: number, text: string): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
const msg = await this.api.sendMessage(chatId, text, { disable_notification: true });
|
|
72
|
+
this.settings.update(chatId, { statusMessageId: msg.message_id });
|
|
73
|
+
await this.api.pinChatMessage(chatId, msg.message_id, { disable_notification: true });
|
|
74
|
+
} catch (err) {
|
|
75
|
+
log.debug("status create/pin failed:", (err as Error).message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionService — turns Kiro's ACP `session/request_permission` into inline
|
|
3
|
+
* Approve/Deny buttons. It names the session that needs approval, sends the
|
|
4
|
+
* prompt WITH sound (it requires interaction), and — when the request belongs to
|
|
5
|
+
* a *background* session — adds a "🔀 Switch to it" button. The Allow/Deny
|
|
6
|
+
* buttons resolve the request in place, without switching.
|
|
7
|
+
*/
|
|
8
|
+
import type { Api } from "grammy";
|
|
9
|
+
import { InlineKeyboard } from "grammy";
|
|
10
|
+
import type { PermissionOutcome, RequestPermissionParams } from "../acp/types.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("permissions");
|
|
15
|
+
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const KIND_ICON: Record<string, string> = {
|
|
18
|
+
read: "\u{1F4D6}",
|
|
19
|
+
edit: "\u270F\uFE0F",
|
|
20
|
+
execute: "\u{1F4BB}",
|
|
21
|
+
delete: "\u{1F5D1}\uFE0F",
|
|
22
|
+
move: "\u{1F4E6}",
|
|
23
|
+
fetch: "\u{1F310}",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Pending {
|
|
27
|
+
resolve: (o: PermissionOutcome) => void;
|
|
28
|
+
options: RequestPermissionParams["options"];
|
|
29
|
+
chatId: number;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
messageId?: number;
|
|
32
|
+
timer: NodeJS.Timeout;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class PermissionService {
|
|
36
|
+
private readonly pending = new Map<string, Pending>();
|
|
37
|
+
private seq = 0;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly api: Api,
|
|
41
|
+
private readonly registry: RuntimeRegistry,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
/** Handle a permission request: ask the owning chat, or auto-allow if none. */
|
|
45
|
+
async handle(params: RequestPermissionParams): Promise<PermissionOutcome> {
|
|
46
|
+
const desc = this.registry.describeSession(params.sessionId);
|
|
47
|
+
const chatId = desc.chatId;
|
|
48
|
+
if (chatId === undefined) return autoDecide(params); // unattended (e.g. scheduled task / orphan subagent)
|
|
49
|
+
|
|
50
|
+
const reqId = String(++this.seq);
|
|
51
|
+
const isForeground = !desc.subagent && this.registry.get(chatId).sessionId === params.sessionId;
|
|
52
|
+
// A "Switch to it" button only makes sense for a real, controlled background
|
|
53
|
+
// session — never for the foreground, and never for a subagent (which the
|
|
54
|
+
// chat doesn't control directly).
|
|
55
|
+
const canSwitch = desc.controlled && !isForeground;
|
|
56
|
+
const label = desc.subagent
|
|
57
|
+
? desc.subagentName || "subagent"
|
|
58
|
+
: desc.projectName || params.sessionId.slice(0, 8);
|
|
59
|
+
|
|
60
|
+
const kb = new InlineKeyboard();
|
|
61
|
+
params.options.forEach((o, i) => kb.text(buttonLabel(o), `perm:${reqId}:${i}`));
|
|
62
|
+
kb.row();
|
|
63
|
+
if (canSwitch) kb.text(`\u{1F500} Switch to ${label}`, `permsw:${reqId}`);
|
|
64
|
+
|
|
65
|
+
let messageId: number | undefined;
|
|
66
|
+
try {
|
|
67
|
+
const msg = await this.api.sendMessage(
|
|
68
|
+
chatId,
|
|
69
|
+
describe(params, { label: isForeground ? undefined : label, subagent: desc.subagent, canSwitch }),
|
|
70
|
+
{
|
|
71
|
+
reply_markup: kb,
|
|
72
|
+
disable_notification: false, // requires interaction → always with sound
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
messageId = msg.message_id;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
log.warn("failed to send permission prompt:", (e as Error).message);
|
|
78
|
+
return autoDecide(params);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return new Promise<PermissionOutcome>((resolve) => {
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
this.pending.delete(reqId);
|
|
84
|
+
void this.api.editMessageText(chatId, messageId!, "\u231B Approval timed out \u2014 denied.").catch(() => {});
|
|
85
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
86
|
+
}, TIMEOUT_MS);
|
|
87
|
+
this.pending.set(reqId, { resolve, options: params.options, chatId, sessionId: params.sessionId, messageId, timer });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolve a pending request from a button tap; returns the chosen label. */
|
|
92
|
+
resolveChoice(reqId: string, index: number): string | undefined {
|
|
93
|
+
const p = this.pending.get(reqId);
|
|
94
|
+
if (!p) return undefined;
|
|
95
|
+
clearTimeout(p.timer);
|
|
96
|
+
this.pending.delete(reqId);
|
|
97
|
+
const opt = p.options[index];
|
|
98
|
+
if (!opt) {
|
|
99
|
+
p.resolve({ outcome: { outcome: "cancelled" } });
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
p.resolve({ outcome: { outcome: "selected", optionId: opt.optionId } });
|
|
103
|
+
return opt.name;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The session a pending request belongs to (for the Switch button). */
|
|
107
|
+
sessionFor(reqId: string): string | undefined {
|
|
108
|
+
return this.pending.get(reqId)?.sessionId;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describe(
|
|
113
|
+
params: RequestPermissionParams,
|
|
114
|
+
ctx: { label?: string; subagent: boolean; canSwitch: boolean },
|
|
115
|
+
): string {
|
|
116
|
+
const tc = params.toolCall;
|
|
117
|
+
const kind = (tc?.kind || "other").toLowerCase();
|
|
118
|
+
const icon = KIND_ICON[kind] ?? "\u{1F527}";
|
|
119
|
+
const title = tc?.title || kind;
|
|
120
|
+
const raw = (tc?.rawInput || {}) as Record<string, unknown>;
|
|
121
|
+
const cmd = typeof raw.command === "string" ? raw.command : undefined;
|
|
122
|
+
const path = typeof raw.path === "string" ? raw.path : undefined;
|
|
123
|
+
const detail = cmd ? `\n\n$ ${cmd}` : path ? `\n\n${path}` : "";
|
|
124
|
+
const who = ctx.subagent
|
|
125
|
+
? `\u{1F916}\u{1F510} Subagent "${ctx.label}" needs approval to run a tool:`
|
|
126
|
+
: ctx.label
|
|
127
|
+
? `\u{1F510} Session "${ctx.label}" needs approval to run a tool:`
|
|
128
|
+
: "\u{1F510} Kiro wants to run a tool:";
|
|
129
|
+
const tail = ctx.canSwitch
|
|
130
|
+
? "\n\nApprove here (no switch), or \u{1F500} switch to that session."
|
|
131
|
+
: ctx.subagent
|
|
132
|
+
? "\n\nApprove for the subagent to continue?"
|
|
133
|
+
: "\n\nApprove?";
|
|
134
|
+
return `${who}\n${icon} ${title}${detail}${tail}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buttonLabel(o: { name: string; kind?: string }): string {
|
|
138
|
+
const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
|
|
139
|
+
const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
|
|
140
|
+
return `${icon} ${o.name}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Pick an allow option when nobody can be asked (otherwise cancel). */
|
|
144
|
+
function autoDecide(params: RequestPermissionParams): PermissionOutcome {
|
|
145
|
+
const allow = params.options.find((o) => /allow|approve|yes|once/i.test(`${o.kind ?? ""} ${o.name}`));
|
|
146
|
+
return allow
|
|
147
|
+
? { outcome: { outcome: "selected", optionId: allow.optionId } }
|
|
148
|
+
: { outcome: { outcome: "cancelled" } };
|
|
149
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build ACP prompt content blocks from a PromptInput (text + images), applying
|
|
3
|
+
* the reasoning directive and any fork-priming context. Also merges multiple
|
|
4
|
+
* queued inputs into one.
|
|
5
|
+
*/
|
|
6
|
+
import type { ContentBlock } from "../acp/types.js";
|
|
7
|
+
import type { PromptInput } from "../app/types.js";
|
|
8
|
+
|
|
9
|
+
export interface ContentOptions {
|
|
10
|
+
reasoning?: string;
|
|
11
|
+
priming?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
|
|
15
|
+
const blocks: ContentBlock[] = [];
|
|
16
|
+
|
|
17
|
+
for (const img of input.images) {
|
|
18
|
+
blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let text = input.text.trim();
|
|
22
|
+
if (!text && input.images.length > 0) {
|
|
23
|
+
text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
|
|
24
|
+
}
|
|
25
|
+
if (opts.priming) {
|
|
26
|
+
text = `${opts.priming}\n\n---\n\nUser's new message:\n${text}`;
|
|
27
|
+
}
|
|
28
|
+
if (opts.reasoning) {
|
|
29
|
+
text = `(${opts.reasoning})\n\n${text}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
blocks.push({ type: "text", text });
|
|
33
|
+
return blocks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Merge queued inputs into a single prompt (concatenated text, all images). */
|
|
37
|
+
export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
38
|
+
return {
|
|
39
|
+
text: inputs
|
|
40
|
+
.map((i) => i.text)
|
|
41
|
+
.filter((t) => t.trim().length > 0)
|
|
42
|
+
.join("\n\n"),
|
|
43
|
+
images: inputs.flatMap((i) => i.images),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function imageSummary(input: PromptInput): string {
|
|
48
|
+
return input.images.length > 0 ? ` (+${input.images.length} image${input.images.length > 1 ? "s" : ""})` : "";
|
|
49
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient-prompt retry policy + the user-facing copy that goes with it.
|
|
3
|
+
*
|
|
4
|
+
* Policy: when a prompt fails with a *transient* agent error (e.g. "high volume
|
|
5
|
+
* of traffic" / -32603 "Internal error") **before any output streamed**, wait
|
|
6
|
+
* and retry with an exponential backoff that starts at 6s and doubles up to a
|
|
7
|
+
* 60s (1 minute) cap, then gives up with a summary. The user always sees the
|
|
8
|
+
* real error text on every attempt — we only add the retry/▶ summary line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** First backoff delay (ms). */
|
|
12
|
+
export const RETRY_BASE_MS = 6_000;
|
|
13
|
+
/** Maximum backoff delay (ms) — "up to 1 minute". */
|
|
14
|
+
export const RETRY_CAP_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Backoff delays (ms) preceding each retry, doubling from {@link RETRY_BASE_MS}
|
|
18
|
+
* and capped at {@link RETRY_CAP_MS}. The schedule stops once it hits the cap,
|
|
19
|
+
* and never exceeds `maxRetries` entries.
|
|
20
|
+
*
|
|
21
|
+
* `maxRetries >= 5` ⇒ `[6000, 12000, 24000, 48000, 60000]`.
|
|
22
|
+
*/
|
|
23
|
+
export function backoffSchedule(maxRetries: number): number[] {
|
|
24
|
+
const out: number[] = [];
|
|
25
|
+
let delay = RETRY_BASE_MS;
|
|
26
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
27
|
+
out.push(Math.min(delay, RETRY_CAP_MS));
|
|
28
|
+
if (delay >= RETRY_CAP_MS) break;
|
|
29
|
+
delay *= 2;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Human-friendly seconds label, e.g. 6000 → "6s", 90000 → "1m 30s". */
|
|
35
|
+
export function fmtSeconds(ms: number): string {
|
|
36
|
+
const s = Math.round(ms / 1000);
|
|
37
|
+
if (s < 60) return `${s}s`;
|
|
38
|
+
const m = Math.floor(s / 60);
|
|
39
|
+
const rem = s % 60;
|
|
40
|
+
return rem ? `${m}m ${rem}s` : `${m}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Message shown when an attempt fails but another retry is scheduled. Shows the
|
|
45
|
+
* real error verbatim so the user can act on it (e.g. switch model), plus when
|
|
46
|
+
* the next attempt runs.
|
|
47
|
+
*/
|
|
48
|
+
export function formatRetryNotice(
|
|
49
|
+
error: Error,
|
|
50
|
+
nextAttempt: number,
|
|
51
|
+
totalAttempts: number,
|
|
52
|
+
waitMs: number,
|
|
53
|
+
): string {
|
|
54
|
+
return [
|
|
55
|
+
`\u26A0\uFE0F ${error.message}`,
|
|
56
|
+
"",
|
|
57
|
+
`\u{1F501} Retrying in ${fmtSeconds(waitMs)} \u2014 attempt ${nextAttempt} of ${totalAttempts}\u2026`,
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Final summary shown after all retries are exhausted (or retry was unsafe). */
|
|
62
|
+
export function formatErrorSummary(error: Error, elapsed: string, attempts: number, transient: boolean): string {
|
|
63
|
+
const tip = transient
|
|
64
|
+
? "\n\n\u{1F4A1} Try a different model (tap \u{1F9E9} Model or /model <id>), or send again later."
|
|
65
|
+
: "";
|
|
66
|
+
if (attempts <= 1) {
|
|
67
|
+
return `\u274C Error after ${elapsed}: ${error.message}${tip}`;
|
|
68
|
+
}
|
|
69
|
+
return `\u274C Gave up after ${attempts} attempts over ${elapsed}.\nLast error: ${error.message}${tip}`;
|
|
70
|
+
}
|