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