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
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ReauthController — drives the whole `/reauth` flow on a SINGLE status message:
|
|
3
|
+
* pick login method → logout → device-flow login → agent restart. Instead of
|
|
4
|
+
* echoing every spinner frame the CLI emits, it shows one self-animated loader
|
|
5
|
+
* line plus inline controls (a method picker up front, Cancel while running,
|
|
6
|
+
* Retry / Restart agent on failure).
|
|
7
|
+
*
|
|
8
|
+
* Kiro CLI can authenticate several ways, so the user chooses first instead of
|
|
9
|
+
* always defaulting to one provider:
|
|
10
|
+
* • Builder ID (free) → `--license free`
|
|
11
|
+
* • Google / GitHub (social) → `--license free --social <provider>`
|
|
12
|
+
* • IAM Identity Center (pro) → `--license pro --identity-provider <url> --region <region>`
|
|
13
|
+
* Every method runs through `--use-device-flow` so approval happens on the
|
|
14
|
+
* user's own device (no browser redirect on the bot host).
|
|
15
|
+
*
|
|
16
|
+
* State is kept per chat so the button callbacks (which arrive on a separate
|
|
17
|
+
* update) can cancel the in-flight login or re-run the flow on the same message.
|
|
18
|
+
*/
|
|
19
|
+
import { type Api, InlineKeyboard } from "grammy";
|
|
20
|
+
import type { AcpClient } from "../acp/client.js";
|
|
21
|
+
import { AuthService } from "../app/auth-service.js";
|
|
22
|
+
import type { AccountInfo } from "../app/usage.js";
|
|
23
|
+
import { createLogger } from "../logger.js";
|
|
24
|
+
import { parseDeviceFlow } from "../render/device-flow.js";
|
|
25
|
+
|
|
26
|
+
const log = createLogger("reauth");
|
|
27
|
+
|
|
28
|
+
/** A 7-segment bar we cycle ourselves — one line, throttled, "infinite" loader. */
|
|
29
|
+
const LOADER = ["▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰▰▱▱▱▱", "▰▰▰▰▱▱▱", "▰▰▰▰▰▱▱", "▰▰▰▰▰▰▱", "▰▰▰▰▰▰▰"];
|
|
30
|
+
const ANIM_MS = 2500;
|
|
31
|
+
const LOGIN_TIMEOUT_MS = 300_000;
|
|
32
|
+
|
|
33
|
+
/** Login methods exposed in the picker. */
|
|
34
|
+
export type LoginMethod = "builder" | "google" | "github" | "idc";
|
|
35
|
+
|
|
36
|
+
/** CLI flags for a chosen method (`--use-device-flow` is added by AuthService). */
|
|
37
|
+
function methodArgs(method: LoginMethod, idc?: { url: string; region: string }): string[] {
|
|
38
|
+
switch (method) {
|
|
39
|
+
case "builder":
|
|
40
|
+
return ["--license", "free"];
|
|
41
|
+
case "google":
|
|
42
|
+
return ["--license", "free", "--social", "google"];
|
|
43
|
+
case "github":
|
|
44
|
+
return ["--license", "free", "--social", "github"];
|
|
45
|
+
case "idc":
|
|
46
|
+
return ["--license", "pro", "--identity-provider", idc!.url, "--region", idc!.region];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const METHOD_LABEL: Record<LoginMethod, string> = {
|
|
51
|
+
builder: "Builder ID",
|
|
52
|
+
google: "Google",
|
|
53
|
+
github: "GitHub",
|
|
54
|
+
idc: "IAM Identity Center",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Pull a start URL + region out of the user's free-text IDC reply. */
|
|
58
|
+
function parseIdcInput(text: string): { url: string; region: string } | undefined {
|
|
59
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
60
|
+
const url = tokens.find((t) => /^https?:\/\//i.test(t));
|
|
61
|
+
const region = tokens.find((t) => /^[a-z]{2}-[a-z]+-\d+$/i.test(t));
|
|
62
|
+
if (!url || !region) return undefined;
|
|
63
|
+
return { url, region };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Phase =
|
|
67
|
+
| "choosing"
|
|
68
|
+
| "idc_input"
|
|
69
|
+
| "logout"
|
|
70
|
+
| "login"
|
|
71
|
+
| "restarting"
|
|
72
|
+
| "done"
|
|
73
|
+
| "failed_login"
|
|
74
|
+
| "failed_restart"
|
|
75
|
+
| "cancelled";
|
|
76
|
+
const ACTIVE: ReadonlySet<Phase> = new Set<Phase>(["logout", "login", "restarting"]);
|
|
77
|
+
|
|
78
|
+
/** Human label for the logged-in identity, for the success message. */
|
|
79
|
+
function accountLabel(a: AccountInfo | undefined): string | undefined {
|
|
80
|
+
if (!a) return undefined;
|
|
81
|
+
return a.email || a.startUrl || a.accountType;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ReauthSession {
|
|
85
|
+
chatId: number;
|
|
86
|
+
messageId: number;
|
|
87
|
+
phase: Phase;
|
|
88
|
+
extra: string[];
|
|
89
|
+
abort?: AbortController;
|
|
90
|
+
anim?: NodeJS.Timeout;
|
|
91
|
+
frame: number;
|
|
92
|
+
url?: string;
|
|
93
|
+
code?: string;
|
|
94
|
+
errorMsg?: string;
|
|
95
|
+
accountLabel?: string;
|
|
96
|
+
lastText?: string;
|
|
97
|
+
/** Chosen login method, for the success message and IDC follow-up. */
|
|
98
|
+
method?: LoginMethod;
|
|
99
|
+
/** IAM Identity Center start URL + region (only set for the `idc` method). */
|
|
100
|
+
idc?: { url: string; region: string };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class ReauthController {
|
|
104
|
+
private readonly auth: AuthService;
|
|
105
|
+
private readonly sessions = new Map<number, ReauthSession>();
|
|
106
|
+
|
|
107
|
+
constructor(
|
|
108
|
+
private readonly api: Api,
|
|
109
|
+
private readonly acp: AcpClient,
|
|
110
|
+
kiroCliPath: string,
|
|
111
|
+
/** Resolves the current account (kiro-cli whoami) to confirm the identity. */
|
|
112
|
+
private readonly getAccount?: () => Promise<AccountInfo | undefined>,
|
|
113
|
+
) {
|
|
114
|
+
this.auth = new AuthService(kiroCliPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True while a reauth flow is actively running for a chat. */
|
|
118
|
+
isBusy(chatId: number): boolean {
|
|
119
|
+
const s = this.sessions.get(chatId);
|
|
120
|
+
return !!s && ACTIVE.has(s.phase);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True while ANY chat is mid-reauth. Logout/login and the restart all touch
|
|
124
|
+
* the single shared agent and global credentials, so only one may run. */
|
|
125
|
+
private anyActive(): boolean {
|
|
126
|
+
for (const s of this.sessions.values()) if (ACTIVE.has(s.phase)) return true;
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Show the login-method picker on a fresh (or reused) status message. */
|
|
131
|
+
async chooseMethod(chatId: number, existingMessageId?: number): Promise<void> {
|
|
132
|
+
if (this.isBusy(chatId)) return;
|
|
133
|
+
let messageId = existingMessageId;
|
|
134
|
+
if (messageId === undefined) {
|
|
135
|
+
const m = await this.api.sendMessage(chatId, "\u{1F510} Re-authenticate Kiro\u2026").catch(() => undefined);
|
|
136
|
+
if (!m) return;
|
|
137
|
+
messageId = m.message_id;
|
|
138
|
+
}
|
|
139
|
+
const s: ReauthSession = { chatId, messageId, phase: "choosing", extra: [], frame: 0 };
|
|
140
|
+
this.sessions.set(chatId, s);
|
|
141
|
+
await this.render(s);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Handle a method choice from the picker. IDC needs a URL + region first. */
|
|
145
|
+
async pickMethod(chatId: number, messageId: number, method: LoginMethod): Promise<void> {
|
|
146
|
+
const s = this.sessions.get(chatId);
|
|
147
|
+
if (!s || s.phase !== "choosing") return; // stale/expired picker
|
|
148
|
+
s.messageId = messageId;
|
|
149
|
+
s.method = method;
|
|
150
|
+
if (method === "idc") {
|
|
151
|
+
s.phase = "idc_input";
|
|
152
|
+
s.errorMsg = undefined;
|
|
153
|
+
s.lastText = undefined;
|
|
154
|
+
await this.render(s);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
await this.begin(chatId, methodArgs(method), messageId, method);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** True while waiting for the user to type their IDC start URL + region. */
|
|
161
|
+
awaitingIdcInput(chatId: number): boolean {
|
|
162
|
+
return this.sessions.get(chatId)?.phase === "idc_input";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Consume the IDC start URL + region text and kick off the login. */
|
|
166
|
+
async submitIdcInput(chatId: number, text: string): Promise<void> {
|
|
167
|
+
const s = this.sessions.get(chatId);
|
|
168
|
+
if (!s || s.phase !== "idc_input") return;
|
|
169
|
+
const parsed = parseIdcInput(text);
|
|
170
|
+
if (!parsed) {
|
|
171
|
+
s.errorMsg = "Couldn't read a start URL and region. Example: https://my-org.awsapps.com/start us-east-1";
|
|
172
|
+
s.lastText = undefined;
|
|
173
|
+
await this.render(s);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await this.begin(chatId, [], s.messageId, "idc", parsed);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Cancel an in-progress picker / IDC prompt (before any logout happened). */
|
|
180
|
+
async cancelChoice(chatId: number, messageId: number): Promise<void> {
|
|
181
|
+
const s = this.sessions.get(chatId);
|
|
182
|
+
if (s && (s.phase === "choosing" || s.phase === "idc_input")) this.sessions.delete(chatId);
|
|
183
|
+
await this.api.editMessageText(chatId, messageId, "\u{1F510} Re-authentication cancelled.").catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Start (or restart) the flow. Reuses `existingMessageId` for the Retry button. */
|
|
187
|
+
async begin(
|
|
188
|
+
chatId: number,
|
|
189
|
+
extra: string[],
|
|
190
|
+
existingMessageId?: number,
|
|
191
|
+
method?: LoginMethod,
|
|
192
|
+
idc?: { url: string; region: string },
|
|
193
|
+
): Promise<void> {
|
|
194
|
+
if (this.isBusy(chatId)) return;
|
|
195
|
+
if (this.anyActive()) {
|
|
196
|
+
await this.api
|
|
197
|
+
.sendMessage(chatId, "\u{1F510} A re-authentication is already in progress in another chat — try again shortly.")
|
|
198
|
+
.catch(() => {});
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (this.acp.hasInflightPrompt()) {
|
|
202
|
+
await this.api
|
|
203
|
+
.sendMessage(chatId, "\u23F3 Kiro is busy running a turn — try /reauth when idle (or /cancel first).")
|
|
204
|
+
.catch(() => {});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
let messageId = existingMessageId;
|
|
208
|
+
if (messageId === undefined) {
|
|
209
|
+
const m = await this.api.sendMessage(chatId, "\u{1F510} Re-authenticating Kiro\u2026").catch(() => undefined);
|
|
210
|
+
if (!m) return;
|
|
211
|
+
messageId = m.message_id;
|
|
212
|
+
}
|
|
213
|
+
const s: ReauthSession = { chatId, messageId, phase: "logout", extra, frame: 0, method, idc };
|
|
214
|
+
this.sessions.set(chatId, s);
|
|
215
|
+
void this.run(s);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Abort the in-flight login/logout for a chat. Returns false if idle. */
|
|
219
|
+
cancel(chatId: number): boolean {
|
|
220
|
+
const s = this.sessions.get(chatId);
|
|
221
|
+
if (!s || !ACTIVE.has(s.phase)) return false;
|
|
222
|
+
s.abort?.abort();
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Re-run the whole flow on the existing status message (Retry button). */
|
|
227
|
+
async retry(chatId: number, messageId: number): Promise<void> {
|
|
228
|
+
const prev = this.sessions.get(chatId);
|
|
229
|
+
await this.begin(chatId, prev?.extra ?? [], messageId, prev?.method, prev?.idc);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Just restart the agent again (Restart-agent button after a restart failure). */
|
|
233
|
+
async restartAgent(chatId: number, messageId: number): Promise<void> {
|
|
234
|
+
if (this.isBusy(chatId) || this.anyActive()) return;
|
|
235
|
+
const s: ReauthSession = this.sessions.get(chatId) ?? { chatId, messageId, phase: "restarting", extra: [], frame: 0 };
|
|
236
|
+
s.messageId = messageId;
|
|
237
|
+
s.phase = "restarting";
|
|
238
|
+
s.errorMsg = undefined;
|
|
239
|
+
this.sessions.set(chatId, s);
|
|
240
|
+
this.startAnim(s);
|
|
241
|
+
await this.render(s);
|
|
242
|
+
try {
|
|
243
|
+
await this.acp.restart();
|
|
244
|
+
s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
|
|
245
|
+
s.phase = "done";
|
|
246
|
+
} catch (e) {
|
|
247
|
+
s.phase = "failed_restart";
|
|
248
|
+
s.errorMsg = (e as Error).message;
|
|
249
|
+
}
|
|
250
|
+
this.stopAnim(s);
|
|
251
|
+
await this.render(s);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── flow ───────────────────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
private async run(s: ReauthSession): Promise<void> {
|
|
257
|
+
s.abort = new AbortController();
|
|
258
|
+
s.accountLabel = undefined;
|
|
259
|
+
// The ACP agent is shared; while it runs it keeps refreshing and rewriting
|
|
260
|
+
// the cached token, which would silently restore the OLD identity right
|
|
261
|
+
// after we log out. So we take it down FIRST and only bring it back once a
|
|
262
|
+
// fresh login has written new credentials.
|
|
263
|
+
let agentDown = false;
|
|
264
|
+
try {
|
|
265
|
+
s.phase = "logout";
|
|
266
|
+
this.startAnim(s);
|
|
267
|
+
await this.render(s);
|
|
268
|
+
|
|
269
|
+
await this.acp.stopAndWait(); // release the held session before logout
|
|
270
|
+
agentDown = true;
|
|
271
|
+
await this.auth.logout();
|
|
272
|
+
await this.auth.clearTokenCache(); // ensure login can't reuse a cached token
|
|
273
|
+
if (s.abort.signal.aborted) {
|
|
274
|
+
s.phase = "cancelled";
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
s.phase = "login";
|
|
279
|
+
s.url = undefined;
|
|
280
|
+
s.code = undefined;
|
|
281
|
+
await this.render(s);
|
|
282
|
+
let raw = "";
|
|
283
|
+
const onOutput = (t: string): void => {
|
|
284
|
+
raw += t;
|
|
285
|
+
this.ingest(s, raw);
|
|
286
|
+
};
|
|
287
|
+
const result =
|
|
288
|
+
s.method === "idc" && s.idc
|
|
289
|
+
? await this.auth.loginIdc({
|
|
290
|
+
startUrl: s.idc.url,
|
|
291
|
+
region: s.idc.region,
|
|
292
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
293
|
+
signal: s.abort.signal,
|
|
294
|
+
onOutput,
|
|
295
|
+
})
|
|
296
|
+
: await this.auth.login({
|
|
297
|
+
extraArgs: s.extra,
|
|
298
|
+
timeoutMs: LOGIN_TIMEOUT_MS,
|
|
299
|
+
signal: s.abort.signal,
|
|
300
|
+
onOutput,
|
|
301
|
+
});
|
|
302
|
+
if (result.cancelled || s.abort.signal.aborted) {
|
|
303
|
+
s.phase = "cancelled";
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!result.ok) {
|
|
307
|
+
s.phase = "failed_login";
|
|
308
|
+
s.errorMsg = result.error ?? `Login did not complete (exit ${result.code ?? "?"}).`;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
s.phase = "restarting";
|
|
313
|
+
await this.render(s);
|
|
314
|
+
try {
|
|
315
|
+
await this.acp.restart(); // fresh agent picks up the new credentials
|
|
316
|
+
agentDown = false;
|
|
317
|
+
s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
|
|
318
|
+
s.phase = "done";
|
|
319
|
+
} catch (e) {
|
|
320
|
+
s.phase = "failed_restart";
|
|
321
|
+
s.errorMsg = (e as Error).message;
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
log.warn("reauth flow failed:", (e as Error).message);
|
|
325
|
+
s.phase = "failed_login";
|
|
326
|
+
s.errorMsg = (e as Error).message;
|
|
327
|
+
} finally {
|
|
328
|
+
s.abort = undefined;
|
|
329
|
+
this.stopAnim(s);
|
|
330
|
+
// If we took the agent down but never brought it back (cancel / login
|
|
331
|
+
// failure), restart it so the bot isn't left without a live agent. A
|
|
332
|
+
// failed_restart already tried — leave its button instead.
|
|
333
|
+
if (agentDown && (s.phase === "cancelled" || s.phase === "failed_login")) {
|
|
334
|
+
await this.acp.restart().catch((e) => log.warn("post-reauth agent restart failed:", (e as Error).message));
|
|
335
|
+
}
|
|
336
|
+
await this.render(s);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Pull stable URL/code out of the streaming output; re-render when they change. */
|
|
341
|
+
private ingest(s: ReauthSession, raw: string): void {
|
|
342
|
+
const p = parseDeviceFlow(raw);
|
|
343
|
+
let changed = false;
|
|
344
|
+
if (p.url && p.url !== s.url) {
|
|
345
|
+
s.url = p.url;
|
|
346
|
+
changed = true;
|
|
347
|
+
}
|
|
348
|
+
if (p.code && p.code !== s.code) {
|
|
349
|
+
s.code = p.code;
|
|
350
|
+
changed = true;
|
|
351
|
+
}
|
|
352
|
+
if (changed) void this.render(s);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
private startAnim(s: ReauthSession): void {
|
|
356
|
+
if (s.anim) return;
|
|
357
|
+
s.anim = setInterval(() => {
|
|
358
|
+
s.frame++;
|
|
359
|
+
void this.render(s);
|
|
360
|
+
}, ANIM_MS);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private stopAnim(s: ReauthSession): void {
|
|
364
|
+
if (s.anim) {
|
|
365
|
+
clearInterval(s.anim);
|
|
366
|
+
s.anim = undefined;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── rendering ────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
private text(s: ReauthSession): string {
|
|
373
|
+
const loader = LOADER[s.frame % LOADER.length] ?? "";
|
|
374
|
+
switch (s.phase) {
|
|
375
|
+
case "choosing":
|
|
376
|
+
return "\u{1F510} Re-authenticate Kiro\nChoose how you want to log in:";
|
|
377
|
+
case "idc_input":
|
|
378
|
+
return (
|
|
379
|
+
"\u{1F3E2} IAM Identity Center (Pro)\n\n" +
|
|
380
|
+
"Send your start URL and Region in one message, separated by a space:\n" +
|
|
381
|
+
"https://my-org.awsapps.com/start us-east-1" +
|
|
382
|
+
(s.errorMsg ? `\n\n\u26A0\uFE0F ${s.errorMsg}` : "")
|
|
383
|
+
);
|
|
384
|
+
case "logout":
|
|
385
|
+
return `\u{1F510} Re-authenticating Kiro\u2026\n\u{1F6AA} Logging out\u2026 ${loader}`;
|
|
386
|
+
case "login": {
|
|
387
|
+
const provider = s.method ? ` \u00B7 ${METHOD_LABEL[s.method]}` : "";
|
|
388
|
+
const lines = [`\u{1F511} Kiro login (device flow)${provider}`, ""];
|
|
389
|
+
if (s.url) lines.push(`\u{1F517} Open this link to approve:\n${s.url}`, "");
|
|
390
|
+
if (s.code) lines.push(`\u{1F522} Verification code: ${s.code}`, "");
|
|
391
|
+
if (!s.url && !s.code) lines.push("Starting device-flow login\u2026", "");
|
|
392
|
+
else lines.push("Confirm it in the browser, then this updates automatically.", "");
|
|
393
|
+
lines.push(`${loader} Waiting for approval\u2026`);
|
|
394
|
+
return lines.join("\n");
|
|
395
|
+
}
|
|
396
|
+
case "restarting":
|
|
397
|
+
return `\u2705 Logged in.\n\u{1F504} Restarting the Kiro agent\u2026 ${loader}`;
|
|
398
|
+
case "done":
|
|
399
|
+
return (
|
|
400
|
+
`\u2705 Re-authenticated${s.accountLabel ? ` as ${s.accountLabel}` : ""} and agent restarted.\n` +
|
|
401
|
+
"Your session re-binds on the next message." +
|
|
402
|
+
(s.accountLabel ? "" : "\n(Tip: /usage shows the active account.)")
|
|
403
|
+
);
|
|
404
|
+
case "cancelled":
|
|
405
|
+
return "\u{1F6D1} Login cancelled \u2014 you're logged out. Tap Retry, or Change method to pick another.";
|
|
406
|
+
case "failed_login":
|
|
407
|
+
return (
|
|
408
|
+
`\u274C ${s.errorMsg ?? "Login failed."}\n` +
|
|
409
|
+
"Tap Retry to try the same method again, or Change method to pick another."
|
|
410
|
+
);
|
|
411
|
+
case "failed_restart":
|
|
412
|
+
return `\u26A0\uFE0F Logged in, but the agent restart failed: ${s.errorMsg ?? "unknown error"}.`;
|
|
413
|
+
default:
|
|
414
|
+
return "";
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private keyboard(s: ReauthSession): InlineKeyboard | undefined {
|
|
419
|
+
switch (s.phase) {
|
|
420
|
+
case "choosing":
|
|
421
|
+
return new InlineKeyboard()
|
|
422
|
+
.text("\u{1F193} Builder ID (free)", "reauth:method:builder")
|
|
423
|
+
.row()
|
|
424
|
+
.text("\u{1F310} Google", "reauth:method:google")
|
|
425
|
+
.text("\u{1F431} GitHub", "reauth:method:github")
|
|
426
|
+
.row()
|
|
427
|
+
.text("\u{1F3E2} IAM Identity Center", "reauth:method:idc")
|
|
428
|
+
.row()
|
|
429
|
+
.text("\u274C Cancel", "reauth:choose-cancel");
|
|
430
|
+
case "idc_input":
|
|
431
|
+
return new InlineKeyboard()
|
|
432
|
+
.text("\u2B05 Back", "reauth:choose-back")
|
|
433
|
+
.text("\u274C Cancel", "reauth:choose-cancel");
|
|
434
|
+
case "logout":
|
|
435
|
+
case "login":
|
|
436
|
+
return new InlineKeyboard().text("\u274C Cancel", "reauth:cancel");
|
|
437
|
+
case "cancelled":
|
|
438
|
+
case "failed_login":
|
|
439
|
+
return new InlineKeyboard()
|
|
440
|
+
.text("\u{1F501} Retry", "reauth:retry")
|
|
441
|
+
.text("\u{1F504} Change method", "reauth:choose-back");
|
|
442
|
+
case "failed_restart":
|
|
443
|
+
return new InlineKeyboard()
|
|
444
|
+
.text("\u{1F504} Restart agent", "reauth:restart")
|
|
445
|
+
.text("\u{1F501} Retry login", "reauth:retry");
|
|
446
|
+
default:
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private async render(s: ReauthSession): Promise<void> {
|
|
452
|
+
const text = this.text(s);
|
|
453
|
+
if (text === s.lastText) return; // unchanged → skip ("message is not modified")
|
|
454
|
+
s.lastText = text;
|
|
455
|
+
await this.api
|
|
456
|
+
.editMessageText(s.chatId, s.messageId, text, {
|
|
457
|
+
reply_markup: this.keyboard(s),
|
|
458
|
+
link_preview_options: { is_disabled: true },
|
|
459
|
+
})
|
|
460
|
+
.catch(() => {});
|
|
461
|
+
}
|
|
462
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* to the settings store so it survives restarts.
|
|
6
6
|
*/
|
|
7
7
|
import { basename } from "node:path";
|
|
8
|
-
import type
|
|
8
|
+
import { type Api, InlineKeyboard } from "grammy";
|
|
9
9
|
import { type AcpClient, isContextExhaustedError, isTransientAcpError } from "../acp/client.js";
|
|
10
10
|
import type { ContentBlock, PromptResult, SessionUpdate } from "../acp/types.js";
|
|
11
11
|
import type { AppConfig } from "../config.js";
|
|
@@ -15,6 +15,7 @@ import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types
|
|
|
15
15
|
import { createLogger } from "../logger.js";
|
|
16
16
|
import { buildTranscript } from "../sessions/history.js";
|
|
17
17
|
import { sessionHashtags } from "../render/hashtags.js";
|
|
18
|
+
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
18
19
|
import { buildPriming, recentTranscript } from "./session-fork.js";
|
|
19
20
|
import { TailWatcher } from "../sessions/tail.js";
|
|
20
21
|
import type { HistoryEntry } from "../sessions/types.js";
|
|
@@ -62,6 +63,9 @@ export class SessionRuntime {
|
|
|
62
63
|
/** The full Done/summary of the most recent finished turn, replayed when you
|
|
63
64
|
* switch (back) into this session so you see how it ended. */
|
|
64
65
|
private lastCompletion: string | undefined;
|
|
66
|
+
/** Latest task-completion % parsed from the agent's `{progress: N%}` markers,
|
|
67
|
+
* shown as a bar in the status panel and session cards. Reset each turn. */
|
|
68
|
+
private progress: number | undefined;
|
|
65
69
|
/** Subagent sessionId -> last status key shown this turn (dedupe). */
|
|
66
70
|
private subagentShown = new Map<string, string>();
|
|
67
71
|
private turnStartedAt = 0;
|
|
@@ -137,6 +141,21 @@ export class SessionRuntime {
|
|
|
137
141
|
return this.lastCompletion;
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
/** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
|
|
145
|
+
get taskProgress(): number | undefined {
|
|
146
|
+
return this.progress;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
150
|
+
* is monotonic within a turn (it's reset to undefined when a new turn starts),
|
|
151
|
+
* so a streamer recreated mid-turn can't make it jump backwards. */
|
|
152
|
+
private setProgress(pct: number): void {
|
|
153
|
+
const next = Math.max(this.progress ?? 0, pct);
|
|
154
|
+
if (next === this.progress) return;
|
|
155
|
+
this.progress = next;
|
|
156
|
+
this.changed();
|
|
157
|
+
}
|
|
158
|
+
|
|
140
159
|
/** Searchable hashtag footer for this session (project · session · model ·
|
|
141
160
|
* reasoning) — appended to every AI-output surface for this session. */
|
|
142
161
|
get tags(): string {
|
|
@@ -157,7 +176,7 @@ export class SessionRuntime {
|
|
|
157
176
|
if (this.busy && !this.streamer) {
|
|
158
177
|
// Any transient follow-watch of this session is now superseded.
|
|
159
178
|
if (this.watchIsFollow) this.stopWatch();
|
|
160
|
-
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags());
|
|
179
|
+
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
|
|
161
180
|
this.typing.start();
|
|
162
181
|
}
|
|
163
182
|
} else {
|
|
@@ -438,24 +457,26 @@ export class SessionRuntime {
|
|
|
438
457
|
this.shownToolIds = new Set();
|
|
439
458
|
this.fileOps = new Map();
|
|
440
459
|
this.subagentShown = new Map();
|
|
460
|
+
this.progress = undefined; // a new turn = a new task; clear the old bar
|
|
441
461
|
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
442
462
|
// session's previous in-flight turn (avoids duplicated output).
|
|
443
463
|
if (this.watchIsFollow) this.stopWatch();
|
|
444
464
|
const live = this.foreground;
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
this.turnStartedAt = startedAt;
|
|
445
467
|
this.streamer = live
|
|
446
|
-
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags())
|
|
468
|
+
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
|
|
447
469
|
: undefined;
|
|
448
470
|
if (live) this.typing.start();
|
|
449
471
|
this.activity(true);
|
|
450
472
|
this.changed();
|
|
451
|
-
const startedAt = Date.now();
|
|
452
|
-
this.turnStartedAt = startedAt;
|
|
453
473
|
this.imageScanText = "";
|
|
454
474
|
this.sentImagesThisTurn = new Set();
|
|
455
475
|
|
|
456
476
|
const content = buildContentBlocks(input, {
|
|
457
477
|
reasoning: reasoningDirective(this.reasoning),
|
|
458
478
|
priming: this.primingContext,
|
|
479
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
459
480
|
});
|
|
460
481
|
this.primingContext = undefined;
|
|
461
482
|
|
|
@@ -464,19 +485,27 @@ export class SessionRuntime {
|
|
|
464
485
|
const recovered = await this.maybeAutoFork(input, outcome);
|
|
465
486
|
const final = recovered ?? outcome;
|
|
466
487
|
const streamedOutput = this.streamer?.hasOutput ?? false;
|
|
488
|
+
// On a successful, non-cancelled turn, top the fallback bar up to 100 (a
|
|
489
|
+
// no-op when the agent reported its own progress — its value is kept).
|
|
490
|
+
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
467
491
|
if (this.streamer) await this.streamer.finalize();
|
|
468
492
|
if (this.foreground) await this.sendTurnImages();
|
|
469
493
|
// Always build the completion (records `lastCompletion` so switching back
|
|
470
494
|
// to this session can replay its Done + summary). Only PING the chat for
|
|
471
495
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
472
496
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
497
|
+
// A background session about to run a queued follow-up shouldn't ping its
|
|
498
|
+
// interim "Done" — only the final, queue-empty turn announces completion.
|
|
499
|
+
const hasQueued = this.queue.length > 0;
|
|
500
|
+
const switchKb = this.switchKeyboard();
|
|
473
501
|
if (final.result || this.cancelled) {
|
|
474
502
|
const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
475
|
-
|
|
503
|
+
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
504
|
+
if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
476
505
|
} else if (final.error) {
|
|
477
506
|
const transient = isTransientAcpError(final.error);
|
|
478
507
|
const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
479
|
-
if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
|
|
508
|
+
if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
480
509
|
}
|
|
481
510
|
} catch (err) {
|
|
482
511
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
@@ -485,7 +514,7 @@ export class SessionRuntime {
|
|
|
485
514
|
this.lastCompletion = msg;
|
|
486
515
|
if (this.foreground || this.cfg.notifyOtherSessions) {
|
|
487
516
|
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
488
|
-
await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo });
|
|
517
|
+
await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
|
|
489
518
|
}
|
|
490
519
|
} finally {
|
|
491
520
|
this.typing.stop();
|
|
@@ -494,6 +523,10 @@ export class SessionRuntime {
|
|
|
494
523
|
this.activity(false);
|
|
495
524
|
// The in-flight turn we may have been following live is over.
|
|
496
525
|
if (this.watchIsFollow) this.stopWatch();
|
|
526
|
+
// Turn ended (done / stopped / error): drop the live task-progress value so
|
|
527
|
+
// the bar is removed from the status panel, session cards and switch
|
|
528
|
+
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
529
|
+
this.progress = undefined;
|
|
497
530
|
this.changed();
|
|
498
531
|
}
|
|
499
532
|
|
|
@@ -589,6 +622,7 @@ export class SessionRuntime {
|
|
|
589
622
|
const forkContent = buildContentBlocks(input, {
|
|
590
623
|
reasoning: reasoningDirective(this.reasoning),
|
|
591
624
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
625
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
592
626
|
});
|
|
593
627
|
return this.runPromptWithRetries(forkContent);
|
|
594
628
|
}
|
|
@@ -708,6 +742,14 @@ export class SessionRuntime {
|
|
|
708
742
|
return `[${name}${id}]`;
|
|
709
743
|
}
|
|
710
744
|
|
|
745
|
+
/** Inline keyboard offering to switch to this session, attached to background
|
|
746
|
+
* ("From other session") pings so you can jump straight in. Foreground turns
|
|
747
|
+
* are already in view, so they get no button. */
|
|
748
|
+
private switchKeyboard(): InlineKeyboard | undefined {
|
|
749
|
+
if (this.foreground || !this.sessionId) return undefined;
|
|
750
|
+
return new InlineKeyboard().text("\u{1F500} Switch to this session", `run:switch:${this.sessionId}`);
|
|
751
|
+
}
|
|
752
|
+
|
|
711
753
|
/** Searchable Telegram hashtags so you can pull up every message of a session
|
|
712
754
|
* or project by tapping the tag. */
|
|
713
755
|
private hashtags(): string {
|
|
@@ -793,12 +835,16 @@ export class SessionRuntime {
|
|
|
793
835
|
}
|
|
794
836
|
}
|
|
795
837
|
|
|
796
|
-
private async notify(
|
|
838
|
+
private async notify(
|
|
839
|
+
text: string,
|
|
840
|
+
opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
|
|
841
|
+
): Promise<void> {
|
|
797
842
|
try {
|
|
798
843
|
const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
|
|
799
844
|
if (opts?.replyTo !== undefined) {
|
|
800
845
|
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
801
846
|
}
|
|
847
|
+
if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
802
848
|
await this.api.sendMessage(this.chatId, text, extra);
|
|
803
849
|
} catch {
|
|
804
850
|
/* non-fatal */
|
package/src/cli.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import { existsSync, readFileSync } from "node:fs";
|
|
12
12
|
import { join } from "node:path";
|
|
13
|
-
import { INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
|
|
13
|
+
import { ENV_PATH, INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
|
|
14
14
|
import { buildLaunchSpec, getController } from "./service/index.js";
|
|
15
15
|
|
|
16
16
|
const HELP = `Kiro Telegram Bot — CLI
|
|
@@ -18,7 +18,8 @@ const HELP = `Kiro Telegram Bot — CLI
|
|
|
18
18
|
Usage: kiro-tg <command>
|
|
19
19
|
|
|
20
20
|
run Run in the foreground
|
|
21
|
-
setup
|
|
21
|
+
setup [--path] Create/update .env (default ~/.kiro/tg/.env, loaded from
|
|
22
|
+
any folder); --path just prints the resolved .env location
|
|
22
23
|
install Install + start a background service (autostart on boot)
|
|
23
24
|
uninstall Stop + remove the background service
|
|
24
25
|
start Start the service
|
|
@@ -98,9 +99,9 @@ async function main(): Promise<void> {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function preflight(): void {
|
|
101
|
-
const envPath =
|
|
102
|
+
const envPath = ENV_PATH;
|
|
102
103
|
if (!existsSync(envPath)) {
|
|
103
|
-
console.warn(
|
|
104
|
+
console.warn(`⚠ No .env found at ${envPath}. Run \`kiro-tg setup\` and set TELEGRAM_BOT_TOKEN first.`);
|
|
104
105
|
return;
|
|
105
106
|
}
|
|
106
107
|
const env = readFileSync(envPath, "utf-8");
|