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
|
@@ -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";
|
|
@@ -46,6 +47,8 @@ export class StatusPanel {
|
|
|
46
47
|
];
|
|
47
48
|
const subagents = this.registry.subagentSummaryForChat(chatId);
|
|
48
49
|
if (subagents) lines.push(`\u{1F465} Subagents: ${subagents}`);
|
|
50
|
+
const progress = rt.taskProgress;
|
|
51
|
+
if (progress !== undefined) lines.push(`\u{1F4C8} Progress: ${progressBar(progress)}`);
|
|
49
52
|
return lines.join("\n");
|
|
50
53
|
}
|
|
51
54
|
|
|
@@ -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;
|
|
@@ -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,18 @@ 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. */
|
|
150
|
+
private setProgress(pct: number): void {
|
|
151
|
+
if (this.progress === pct) return;
|
|
152
|
+
this.progress = pct;
|
|
153
|
+
this.changed();
|
|
154
|
+
}
|
|
155
|
+
|
|
140
156
|
/** Searchable hashtag footer for this session (project · session · model ·
|
|
141
157
|
* reasoning) — appended to every AI-output surface for this session. */
|
|
142
158
|
get tags(): string {
|
|
@@ -157,7 +173,7 @@ export class SessionRuntime {
|
|
|
157
173
|
if (this.busy && !this.streamer) {
|
|
158
174
|
// Any transient follow-watch of this session is now superseded.
|
|
159
175
|
if (this.watchIsFollow) this.stopWatch();
|
|
160
|
-
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags());
|
|
176
|
+
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct));
|
|
161
177
|
this.typing.start();
|
|
162
178
|
}
|
|
163
179
|
} else {
|
|
@@ -438,12 +454,13 @@ export class SessionRuntime {
|
|
|
438
454
|
this.shownToolIds = new Set();
|
|
439
455
|
this.fileOps = new Map();
|
|
440
456
|
this.subagentShown = new Map();
|
|
457
|
+
this.progress = undefined; // a new turn = a new task; clear the old bar
|
|
441
458
|
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
442
459
|
// session's previous in-flight turn (avoids duplicated output).
|
|
443
460
|
if (this.watchIsFollow) this.stopWatch();
|
|
444
461
|
const live = this.foreground;
|
|
445
462
|
this.streamer = live
|
|
446
|
-
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags())
|
|
463
|
+
? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct))
|
|
447
464
|
: undefined;
|
|
448
465
|
if (live) this.typing.start();
|
|
449
466
|
this.activity(true);
|
|
@@ -456,6 +473,7 @@ export class SessionRuntime {
|
|
|
456
473
|
const content = buildContentBlocks(input, {
|
|
457
474
|
reasoning: reasoningDirective(this.reasoning),
|
|
458
475
|
priming: this.primingContext,
|
|
476
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
459
477
|
});
|
|
460
478
|
this.primingContext = undefined;
|
|
461
479
|
|
|
@@ -470,13 +488,18 @@ export class SessionRuntime {
|
|
|
470
488
|
// to this session can replay its Done + summary). Only PING the chat for
|
|
471
489
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
472
490
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
491
|
+
// A background session about to run a queued follow-up shouldn't ping its
|
|
492
|
+
// interim "Done" — only the final, queue-empty turn announces completion.
|
|
493
|
+
const hasQueued = this.queue.length > 0;
|
|
494
|
+
const switchKb = this.switchKeyboard();
|
|
473
495
|
if (final.result || this.cancelled) {
|
|
474
496
|
const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
475
|
-
|
|
497
|
+
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
498
|
+
if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
476
499
|
} else if (final.error) {
|
|
477
500
|
const transient = isTransientAcpError(final.error);
|
|
478
501
|
const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
479
|
-
if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo });
|
|
502
|
+
if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
480
503
|
}
|
|
481
504
|
} catch (err) {
|
|
482
505
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
@@ -485,7 +508,7 @@ export class SessionRuntime {
|
|
|
485
508
|
this.lastCompletion = msg;
|
|
486
509
|
if (this.foreground || this.cfg.notifyOtherSessions) {
|
|
487
510
|
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
488
|
-
await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo });
|
|
511
|
+
await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
|
|
489
512
|
}
|
|
490
513
|
} finally {
|
|
491
514
|
this.typing.stop();
|
|
@@ -589,6 +612,7 @@ export class SessionRuntime {
|
|
|
589
612
|
const forkContent = buildContentBlocks(input, {
|
|
590
613
|
reasoning: reasoningDirective(this.reasoning),
|
|
591
614
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
615
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
592
616
|
});
|
|
593
617
|
return this.runPromptWithRetries(forkContent);
|
|
594
618
|
}
|
|
@@ -708,6 +732,14 @@ export class SessionRuntime {
|
|
|
708
732
|
return `[${name}${id}]`;
|
|
709
733
|
}
|
|
710
734
|
|
|
735
|
+
/** Inline keyboard offering to switch to this session, attached to background
|
|
736
|
+
* ("From other session") pings so you can jump straight in. Foreground turns
|
|
737
|
+
* are already in view, so they get no button. */
|
|
738
|
+
private switchKeyboard(): InlineKeyboard | undefined {
|
|
739
|
+
if (this.foreground || !this.sessionId) return undefined;
|
|
740
|
+
return new InlineKeyboard().text("\u{1F500} Switch to this session", `run:switch:${this.sessionId}`);
|
|
741
|
+
}
|
|
742
|
+
|
|
711
743
|
/** Searchable Telegram hashtags so you can pull up every message of a session
|
|
712
744
|
* or project by tapping the tag. */
|
|
713
745
|
private hashtags(): string {
|
|
@@ -793,12 +825,16 @@ export class SessionRuntime {
|
|
|
793
825
|
}
|
|
794
826
|
}
|
|
795
827
|
|
|
796
|
-
private async notify(
|
|
828
|
+
private async notify(
|
|
829
|
+
text: string,
|
|
830
|
+
opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
|
|
831
|
+
): Promise<void> {
|
|
797
832
|
try {
|
|
798
833
|
const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
|
|
799
834
|
if (opts?.replyTo !== undefined) {
|
|
800
835
|
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
801
836
|
}
|
|
837
|
+
if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
802
838
|
await this.api.sendMessage(this.chatId, text, extra);
|
|
803
839
|
} catch {
|
|
804
840
|
/* non-fatal */
|
package/src/config.ts
CHANGED
|
@@ -112,6 +112,8 @@ export interface AppConfig {
|
|
|
112
112
|
mcpProbeConcurrency: number;
|
|
113
113
|
/** Show subagent (crew) activity while the main agent waits on them. */
|
|
114
114
|
showSubagents: boolean;
|
|
115
|
+
/** Ask the agent to emit a `{progress: N%}` marker and render it as a bar. */
|
|
116
|
+
showProgress: boolean;
|
|
115
117
|
/** Deliver a turn's "Done" summary to the chat even when that session is in
|
|
116
118
|
* the background (you've switched to another session). */
|
|
117
119
|
notifyOtherSessions: boolean;
|
|
@@ -182,6 +184,7 @@ export function loadConfig(): AppConfig {
|
|
|
182
184
|
mcpProbeTimeoutMs: num(process.env.MCP_PROBE_TIMEOUT_MS, 8000),
|
|
183
185
|
mcpProbeConcurrency: num(process.env.MCP_PROBE_CONCURRENCY, 6),
|
|
184
186
|
showSubagents: bool(process.env.SHOW_SUBAGENTS, true),
|
|
187
|
+
showProgress: bool(process.env.SHOW_PROGRESS, true),
|
|
185
188
|
notifyOtherSessions: bool(process.env.NOTIFY_OTHER_SESSIONS, true),
|
|
186
189
|
autoUpdate: bool(process.env.AUTO_UPDATE, true),
|
|
187
190
|
updateCheckMs: num(process.env.UPDATE_CHECK_MS, 3_600_000),
|