kiro-telegram-bot 1.6.0 → 1.7.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/CHANGELOG.md +99 -0
- package/README.md +40 -6
- package/package.json +4 -1
- package/src/acp/client.ts +85 -13
- package/src/app/auth-service.ts +325 -0
- package/src/bot/bot.ts +4 -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/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 +3 -0
- package/src/bot/prompt-content.ts +5 -0
- package/src/bot/reauth-controller.ts +462 -0
- package/src/bot/session-runtime.ts +43 -7
- package/src/config.ts +3 -0
- package/src/render/device-flow.ts +76 -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 +29 -6
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kiro authentication control for /reauth: `kiro-cli logout` then an interactive
|
|
3
|
+
* `kiro-cli login --use-device-flow`. The device flow prints a verification URL
|
|
4
|
+
* + code to stdout (no browser redirect on the bot host), which we stream back
|
|
5
|
+
* to Telegram so the user can complete it on their own device.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile, spawn } from "node:child_process";
|
|
8
|
+
import { rm } from "node:fs/promises";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { promisify } from "node:util";
|
|
12
|
+
import { createLogger } from "../logger.js";
|
|
13
|
+
|
|
14
|
+
const run = promisify(execFile);
|
|
15
|
+
const log = createLogger("auth");
|
|
16
|
+
|
|
17
|
+
// Strip ANSI colour/cursor escapes so the Telegram transcript stays readable.
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
20
|
+
|
|
21
|
+
export interface LoginResult {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
code: number | null;
|
|
24
|
+
/** True when the login was aborted via the supplied AbortSignal. */
|
|
25
|
+
cancelled?: boolean;
|
|
26
|
+
/** Human-readable failure reason, when known (shown in the chat). */
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface LoginOptions {
|
|
31
|
+
/** Extra CLI flags (e.g. `--license pro`). `--use-device-flow` is added if absent. */
|
|
32
|
+
extraArgs?: string[];
|
|
33
|
+
/** Receives decoded stdout/stderr chunks as they arrive. */
|
|
34
|
+
onOutput: (text: string) => void;
|
|
35
|
+
/** Overall timeout before the login process is killed (default 5 min). */
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** Abort to cancel the in-flight login — kills the process (Cancel button). */
|
|
38
|
+
signal?: AbortSignal;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface IdcLoginOptions {
|
|
42
|
+
/** IAM Identity Center start URL (e.g. https://my-org.awsapps.com/start). */
|
|
43
|
+
startUrl: string;
|
|
44
|
+
/** AWS region of the Identity Center (e.g. us-east-1). */
|
|
45
|
+
region: string;
|
|
46
|
+
/** Receives decoded output chunks as they arrive. */
|
|
47
|
+
onOutput: (text: string) => void;
|
|
48
|
+
/** Overall timeout before the login process is killed (default 5 min). */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
/** Abort to cancel the in-flight login — kills the process (Cancel button). */
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Minimal shape of the optional `node-pty` module we rely on. */
|
|
55
|
+
interface IPty {
|
|
56
|
+
onData(cb: (data: string) => void): void;
|
|
57
|
+
onExit(cb: (e: { exitCode: number; signal?: number }) => void): void;
|
|
58
|
+
write(data: string): void;
|
|
59
|
+
kill(signal?: string): void;
|
|
60
|
+
}
|
|
61
|
+
interface PtyModule {
|
|
62
|
+
spawn(
|
|
63
|
+
file: string,
|
|
64
|
+
args: string[],
|
|
65
|
+
options: { name?: string; cols?: number; rows?: number; cwd?: string; env?: NodeJS.ProcessEnv },
|
|
66
|
+
): IPty;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class AuthService {
|
|
70
|
+
constructor(private readonly kiroCliPath: string) {}
|
|
71
|
+
|
|
72
|
+
/** Run `kiro-cli logout` (non-interactive). */
|
|
73
|
+
async logout(): Promise<{ ok: boolean; out: string }> {
|
|
74
|
+
try {
|
|
75
|
+
const { stdout, stderr } = await run(this.kiroCliPath, ["logout"], {
|
|
76
|
+
timeout: 30_000,
|
|
77
|
+
encoding: "utf-8",
|
|
78
|
+
});
|
|
79
|
+
return { ok: true, out: clean(`${stdout}${stderr}`) };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
82
|
+
const out = clean(`${err.stdout ?? ""}${err.stderr ?? ""}`) || err.message || "logout failed";
|
|
83
|
+
return { ok: false, out };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Best-effort removal of Kiro's cached auth token (`~/.aws/sso/cache/
|
|
89
|
+
* kiro-auth-token.json`) — the file that carries the logged-in identity
|
|
90
|
+
* (accessToken + refreshToken). Removing it after `logout` guarantees the
|
|
91
|
+
* next `login` performs a genuine device-flow authentication instead of
|
|
92
|
+
* silently reusing the previous account's cached/refreshable token.
|
|
93
|
+
*
|
|
94
|
+
* Surgical and safe: it touches ONLY Kiro's own token file, never the shared,
|
|
95
|
+
* account-agnostic OIDC client registrations, and is a no-op if absent.
|
|
96
|
+
*/
|
|
97
|
+
async clearTokenCache(): Promise<boolean> {
|
|
98
|
+
const path = join(homedir(), ".aws", "sso", "cache", "kiro-auth-token.json");
|
|
99
|
+
try {
|
|
100
|
+
await rm(path, { force: true });
|
|
101
|
+
log.info(`cleared cached auth token (${path})`);
|
|
102
|
+
return true;
|
|
103
|
+
} catch (e) {
|
|
104
|
+
log.debug("clearTokenCache failed:", (e as Error).message);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* to `onOutput` as it arrives (so the device code/URL reaches the user fast).
|
|
111
|
+
* Resolves when the process exits, the timeout fires, or the signal aborts.
|
|
112
|
+
*/
|
|
113
|
+
login(opts: LoginOptions): Promise<LoginResult> {
|
|
114
|
+
const { extraArgs = [], onOutput, timeoutMs = 300_000, signal } = opts;
|
|
115
|
+
return new Promise<LoginResult>((resolve) => {
|
|
116
|
+
if (signal?.aborted) {
|
|
117
|
+
resolve({ ok: false, code: null, cancelled: true });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const args = ["login"];
|
|
121
|
+
if (!extraArgs.includes("--use-device-flow")) args.push("--use-device-flow");
|
|
122
|
+
args.push(...extraArgs);
|
|
123
|
+
log.info(`spawning login: ${this.kiroCliPath} ${args.join(" ")}`);
|
|
124
|
+
|
|
125
|
+
let proc;
|
|
126
|
+
try {
|
|
127
|
+
// stdin ignored: any interactive prompt gets EOF rather than hanging.
|
|
128
|
+
proc = spawn(this.kiroCliPath, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
onOutput(`error: ${(e as Error).message}`);
|
|
131
|
+
resolve({ ok: false, code: null });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let cancelled = false;
|
|
136
|
+
let settled = false;
|
|
137
|
+
let hardKill: NodeJS.Timeout | undefined;
|
|
138
|
+
|
|
139
|
+
const onAbort = (): void => {
|
|
140
|
+
cancelled = true;
|
|
141
|
+
try {
|
|
142
|
+
proc.kill();
|
|
143
|
+
} catch {
|
|
144
|
+
/* ignore */
|
|
145
|
+
}
|
|
146
|
+
// Escalate if the CLI ignores the polite signal.
|
|
147
|
+
hardKill = setTimeout(() => {
|
|
148
|
+
try {
|
|
149
|
+
proc.kill("SIGKILL");
|
|
150
|
+
} catch {
|
|
151
|
+
/* ignore */
|
|
152
|
+
}
|
|
153
|
+
}, 2000);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const finish = (r: LoginResult): void => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
if (hardKill) clearTimeout(hardKill);
|
|
161
|
+
signal?.removeEventListener("abort", onAbort);
|
|
162
|
+
resolve(r);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const feed = (b: Buffer): void => {
|
|
166
|
+
const t = clean(b.toString("utf-8"));
|
|
167
|
+
if (t) onOutput(t);
|
|
168
|
+
};
|
|
169
|
+
proc.stdout.on("data", feed);
|
|
170
|
+
proc.stderr.on("data", feed);
|
|
171
|
+
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
onOutput("\n\u23F1\uFE0F Timed out waiting for login to complete.");
|
|
174
|
+
try {
|
|
175
|
+
proc.kill();
|
|
176
|
+
} catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
}, timeoutMs);
|
|
180
|
+
|
|
181
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
182
|
+
|
|
183
|
+
proc.on("error", (e: Error) => {
|
|
184
|
+
onOutput(`error: ${e.message}`);
|
|
185
|
+
finish({ ok: false, code: null, cancelled });
|
|
186
|
+
});
|
|
187
|
+
proc.on("exit", (code: number | null) => {
|
|
188
|
+
finish({ ok: code === 0 && !cancelled, code, cancelled });
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* IAM Identity Center (Pro) login. Unlike the Builder ID device flow, this
|
|
195
|
+
* CLI path is *interactive*: it always prompts ("Enter Start URL", "Enter
|
|
196
|
+
* Region", and — when the account has several — an Identity Center profile
|
|
197
|
+
* picker) and refuses to run without a real terminal. So we drive it inside a
|
|
198
|
+
* pseudo-terminal (the optional `node-pty` module), answering each prompt with
|
|
199
|
+
* the start URL / region the user supplied and accepting the default profile.
|
|
200
|
+
* The device verification URL + code still stream out via `onOutput`.
|
|
201
|
+
*/
|
|
202
|
+
loginIdc(opts: IdcLoginOptions): Promise<LoginResult> {
|
|
203
|
+
const { startUrl, region, onOutput, timeoutMs = 300_000, signal } = opts;
|
|
204
|
+
return new Promise<LoginResult>((resolve) => {
|
|
205
|
+
if (signal?.aborted) {
|
|
206
|
+
resolve({ ok: false, code: null, cancelled: true });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
void (async () => {
|
|
210
|
+
// Load the optional native PTY lazily via a variable specifier so a
|
|
211
|
+
// missing module is a graceful runtime error, not an install/type break.
|
|
212
|
+
let pty: PtyModule;
|
|
213
|
+
try {
|
|
214
|
+
const specifier = "@homebridge/node-pty-prebuilt-multiarch";
|
|
215
|
+
const mod = (await import(specifier)) as unknown as PtyModule & { default?: PtyModule };
|
|
216
|
+
pty = typeof mod.spawn === "function" ? mod : (mod.default as PtyModule);
|
|
217
|
+
if (!pty || typeof pty.spawn !== "function") throw new Error("invalid pty module");
|
|
218
|
+
} catch {
|
|
219
|
+
resolve({
|
|
220
|
+
ok: false,
|
|
221
|
+
code: null,
|
|
222
|
+
error:
|
|
223
|
+
"IAM Identity Center login needs the PTY module. Run `npm install` in the bot folder, then try again.",
|
|
224
|
+
});
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Pass the start URL + region as flags so the interactive prompts come
|
|
229
|
+
// PREFILLED — we then just press Enter to accept them. Typing the values
|
|
230
|
+
// ourselves makes the terminal echo them, which doubles the captured
|
|
231
|
+
// input (e.g. "us-east-1us-east-1" → bad OIDC endpoint). Q_FAKE_IS_REMOTE
|
|
232
|
+
// forces the CLI to PRINT the verification URL instead of opening a
|
|
233
|
+
// browser on the bot host, so it streams to Telegram.
|
|
234
|
+
const args = [
|
|
235
|
+
"login",
|
|
236
|
+
"--license",
|
|
237
|
+
"pro",
|
|
238
|
+
"--identity-provider",
|
|
239
|
+
startUrl,
|
|
240
|
+
"--region",
|
|
241
|
+
region,
|
|
242
|
+
"--use-device-flow",
|
|
243
|
+
];
|
|
244
|
+
log.info(`spawning IDC login (pty): ${this.kiroCliPath} ${args.join(" ")}`);
|
|
245
|
+
|
|
246
|
+
let term: IPty;
|
|
247
|
+
try {
|
|
248
|
+
term = pty.spawn(this.kiroCliPath, args, {
|
|
249
|
+
name: "xterm-color",
|
|
250
|
+
cols: 120,
|
|
251
|
+
rows: 30,
|
|
252
|
+
env: { ...process.env, Q_FAKE_IS_REMOTE: "1" },
|
|
253
|
+
});
|
|
254
|
+
} catch (e) {
|
|
255
|
+
resolve({ ok: false, code: null, error: (e as Error).message });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let buf = "";
|
|
260
|
+
let sentUrl = false;
|
|
261
|
+
let sentRegion = false;
|
|
262
|
+
let sentProfile = false;
|
|
263
|
+
let cancelled = false;
|
|
264
|
+
let settled = false;
|
|
265
|
+
|
|
266
|
+
const onAbort = (): void => {
|
|
267
|
+
cancelled = true;
|
|
268
|
+
try {
|
|
269
|
+
term.kill();
|
|
270
|
+
} catch {
|
|
271
|
+
/* ignore */
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const finish = (r: LoginResult): void => {
|
|
276
|
+
if (settled) return;
|
|
277
|
+
settled = true;
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
signal?.removeEventListener("abort", onAbort);
|
|
280
|
+
try {
|
|
281
|
+
term.kill();
|
|
282
|
+
} catch {
|
|
283
|
+
/* ignore */
|
|
284
|
+
}
|
|
285
|
+
resolve(r);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
const timer = setTimeout(() => {
|
|
289
|
+
onOutput("\n\u23F1\uFE0F Timed out waiting for login to complete.");
|
|
290
|
+
try {
|
|
291
|
+
term.kill();
|
|
292
|
+
} catch {
|
|
293
|
+
/* ignore */
|
|
294
|
+
}
|
|
295
|
+
}, timeoutMs);
|
|
296
|
+
|
|
297
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
298
|
+
|
|
299
|
+
term.onData((d: string) => {
|
|
300
|
+
const t = clean(d);
|
|
301
|
+
if (t) onOutput(t);
|
|
302
|
+
buf += t;
|
|
303
|
+
// Each prompt is PREFILLED (from the flags); just press Enter to accept
|
|
304
|
+
// it. We answer each exactly once, in order.
|
|
305
|
+
if (!sentUrl && /start url/i.test(buf)) {
|
|
306
|
+
sentUrl = true;
|
|
307
|
+
term.write("\r");
|
|
308
|
+
} else if (sentUrl && !sentRegion && /enter region/i.test(buf)) {
|
|
309
|
+
sentRegion = true;
|
|
310
|
+
term.write("\r");
|
|
311
|
+
} else if (sentRegion && !sentProfile && /select an iam identity center profile/i.test(buf)) {
|
|
312
|
+
sentProfile = true;
|
|
313
|
+
term.write("\r"); // accept the default (first) profile
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
term.onExit(({ exitCode }) => finish({ ok: exitCode === 0 && !cancelled, code: exitCode, cancelled }));
|
|
318
|
+
})();
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function clean(s: string): string {
|
|
324
|
+
return s.replace(ANSI_RE, "").replace(/\r/g, "");
|
|
325
|
+
}
|
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";
|
|
@@ -146,9 +148,11 @@ export async function createBot(cfg: AppConfig, acp: AcpClient): Promise<BotBund
|
|
|
146
148
|
registerControl(bot, deps);
|
|
147
149
|
registerProjects(bot, deps);
|
|
148
150
|
registerSessions(bot, deps);
|
|
151
|
+
registerSessionKill(bot, deps);
|
|
149
152
|
registerRunning(bot, deps);
|
|
150
153
|
registerHistory(bot, deps);
|
|
151
154
|
registerSystem(bot, deps);
|
|
155
|
+
registerReauth(bot, deps);
|
|
152
156
|
registerUsage(bot, deps);
|
|
153
157
|
registerKill(bot, deps);
|
|
154
158
|
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
|
+
}
|
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
|
|