kiro-telegram-bot 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +104 -0
- package/LICENSE +21 -0
- package/README.md +517 -0
- package/bin/kiro-tg.mjs +21 -0
- package/docs/INSTALL.md +143 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +70 -0
- package/scripts/mq.ts +25 -0
- package/scripts/setup.mjs +78 -0
- package/src/acp/client.ts +456 -0
- package/src/acp/server-handlers.ts +85 -0
- package/src/acp/transport.ts +50 -0
- package/src/acp/types.ts +136 -0
- package/src/agents/catalog.ts +44 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +31 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +48 -0
- package/src/app/usage.ts +32 -0
- package/src/bot/auth.ts +27 -0
- package/src/bot/bot.ts +154 -0
- package/src/bot/chat-controller.ts +251 -0
- package/src/bot/commands.ts +48 -0
- package/src/bot/deps.ts +47 -0
- package/src/bot/handlers/control.ts +94 -0
- package/src/bot/handlers/history.ts +58 -0
- package/src/bot/handlers/kill.ts +69 -0
- package/src/bot/handlers/mcp.ts +205 -0
- package/src/bot/handlers/menu.ts +204 -0
- package/src/bot/handlers/message.ts +93 -0
- package/src/bot/handlers/photo.ts +108 -0
- package/src/bot/handlers/projects.ts +83 -0
- package/src/bot/handlers/running.ts +104 -0
- package/src/bot/handlers/session-card.ts +65 -0
- package/src/bot/handlers/sessions.ts +131 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +223 -0
- package/src/bot/handlers/usage.ts +33 -0
- package/src/bot/handlers/voice.ts +53 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/keyboard.ts +47 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +78 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +49 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/registry.ts +178 -0
- package/src/bot/session-runtime.ts +670 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +125 -0
- package/src/config.ts +190 -0
- package/src/index.ts +74 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +103 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +88 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/markdown.ts +126 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +102 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +83 -0
- package/src/service/macos.ts +91 -0
- package/src/service/platform.ts +59 -0
- package/src/service/types.ts +34 -0
- package/src/service/windows.ts +103 -0
- package/src/sessions/history.ts +181 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +167 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionRuntime — binds one Telegram chat to one Kiro ACP session and drives
|
|
3
|
+
* the prompt/stream lifecycle, typing indicator, follow-up queue, live watch,
|
|
4
|
+
* and per-chat preferences (project, agent, model, reasoning). State persists
|
|
5
|
+
* to the settings store so it survives restarts.
|
|
6
|
+
*/
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { Api } from "grammy";
|
|
9
|
+
import { type AcpClient, isTransientAcpError } from "../acp/client.js";
|
|
10
|
+
import type { ContentBlock, PromptResult, SessionUpdate } from "../acp/types.js";
|
|
11
|
+
import type { AppConfig } from "../config.js";
|
|
12
|
+
import { reasoningDirective } from "../app/reasoning.js";
|
|
13
|
+
import type { SettingsStore } from "../app/settings-store.js";
|
|
14
|
+
import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
|
|
15
|
+
import { createLogger } from "../logger.js";
|
|
16
|
+
import { buildTranscript, readHistory } from "../sessions/history.js";
|
|
17
|
+
import { TailWatcher } from "../sessions/tail.js";
|
|
18
|
+
import type { HistoryEntry } from "../sessions/types.js";
|
|
19
|
+
import { formatToolCall } from "../render/tool-call.js";
|
|
20
|
+
import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
|
|
21
|
+
import type { PendingStage, SubagentInfo } from "../acp/types.js";
|
|
22
|
+
import { ResponseStreamer } from "../stream/streamer.js";
|
|
23
|
+
import { extractImagePaths, sendImages } from "./image-return.js";
|
|
24
|
+
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
25
|
+
import { backoffSchedule, formatErrorSummary, formatRetryNotice } from "./prompt-retry.js";
|
|
26
|
+
import { sendMarkdownDoc } from "./telegram-io.js";
|
|
27
|
+
import { TypingIndicator } from "./typing.js";
|
|
28
|
+
|
|
29
|
+
const log = createLogger("runtime");
|
|
30
|
+
|
|
31
|
+
const WATCH_ENTRY_MAX = 700;
|
|
32
|
+
const WATCH_ICON: Record<string, string> = {
|
|
33
|
+
user: "\u{1F464}",
|
|
34
|
+
assistant: "\u{1F916}",
|
|
35
|
+
tool: "\u{1F527}",
|
|
36
|
+
system: "\u2139\uFE0F",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type AttachResult = "resumed" | "forked";
|
|
40
|
+
|
|
41
|
+
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
|
|
43
|
+
export class SessionRuntime {
|
|
44
|
+
sessionId: string | undefined;
|
|
45
|
+
cwd: string;
|
|
46
|
+
projectName: string | undefined;
|
|
47
|
+
/** Invoked whenever observable state changes (for the status panel). */
|
|
48
|
+
onStateChange: (() => void) | undefined;
|
|
49
|
+
|
|
50
|
+
private busy = false;
|
|
51
|
+
private cancelled = false;
|
|
52
|
+
private readonly queue: PromptInput[] = [];
|
|
53
|
+
private streamer: ResponseStreamer | undefined;
|
|
54
|
+
private readonly typing: TypingIndicator;
|
|
55
|
+
private shownToolIds = new Set<string>();
|
|
56
|
+
/** Subagent sessionId -> last status key shown this turn (dedupe). */
|
|
57
|
+
private subagentShown = new Map<string, string>();
|
|
58
|
+
private turnStartedAt = 0;
|
|
59
|
+
private imageScanText = "";
|
|
60
|
+
private sentImagesThisTurn = new Set<string>();
|
|
61
|
+
private readonly listener: (sessionId: string, update: SessionUpdate) => void;
|
|
62
|
+
private primingContext: string | undefined;
|
|
63
|
+
private watcher: TailWatcher | undefined;
|
|
64
|
+
/** True when the active watch is a transient "follow" of this session's own
|
|
65
|
+
* in-flight turn (started on switch) rather than an explicit /watch of
|
|
66
|
+
* another session — follow-watches are auto-stopped when a new turn streams. */
|
|
67
|
+
private watchIsFollow = false;
|
|
68
|
+
private rebindPending = false;
|
|
69
|
+
private sessionLive = false;
|
|
70
|
+
/** Only the foreground runtime streams to Telegram; background ones stay quiet
|
|
71
|
+
* (their output lands in the session's .jsonl and shows as "unread" on switch). */
|
|
72
|
+
private foreground = true;
|
|
73
|
+
private readonly restartListener: () => void;
|
|
74
|
+
/** Invoked when this runtime starts/stops a turn (for subagent attribution). */
|
|
75
|
+
onActivity: ((busy: boolean) => void) | undefined;
|
|
76
|
+
|
|
77
|
+
constructor(
|
|
78
|
+
private readonly api: Api,
|
|
79
|
+
private readonly chatId: number,
|
|
80
|
+
private readonly acp: AcpClient,
|
|
81
|
+
private readonly cfg: AppConfig,
|
|
82
|
+
private readonly settings: SettingsStore,
|
|
83
|
+
init?: { cwd: string; projectName?: string; sessionId?: string },
|
|
84
|
+
) {
|
|
85
|
+
if (init) {
|
|
86
|
+
this.cwd = init.cwd;
|
|
87
|
+
this.projectName = init.projectName;
|
|
88
|
+
this.sessionId = init.sessionId;
|
|
89
|
+
} else {
|
|
90
|
+
const s = settings.get(chatId);
|
|
91
|
+
this.cwd = s.projectPath ?? cfg.workspace;
|
|
92
|
+
this.projectName = s.projectName;
|
|
93
|
+
this.sessionId = s.sessionId;
|
|
94
|
+
}
|
|
95
|
+
if (this.sessionId) this.rebindPending = true; // lazily reload on first use
|
|
96
|
+
|
|
97
|
+
this.typing = new TypingIndicator(api, chatId);
|
|
98
|
+
this.listener = (sid, update) => this.onUpdate(sid, update);
|
|
99
|
+
this.acp.on("session-update", this.listener);
|
|
100
|
+
this.restartListener = () => {
|
|
101
|
+
this.sessionLive = false;
|
|
102
|
+
if (this.sessionId) this.rebindPending = true;
|
|
103
|
+
};
|
|
104
|
+
this.acp.on("restarted", this.restartListener);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
get isBusy(): boolean {
|
|
108
|
+
return this.busy;
|
|
109
|
+
}
|
|
110
|
+
get queueLength(): number {
|
|
111
|
+
return this.queue.length;
|
|
112
|
+
}
|
|
113
|
+
get isWatching(): boolean {
|
|
114
|
+
return this.watcher?.running ?? false;
|
|
115
|
+
}
|
|
116
|
+
get isForeground(): boolean {
|
|
117
|
+
return this.foreground;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Switch live-streaming on/off. Going background seals any in-flight turn;
|
|
121
|
+
* returning to the foreground while a turn is still running resumes RICH
|
|
122
|
+
* live streaming (thinking / tools / prose) rather than a degraded tail. */
|
|
123
|
+
async setForeground(value: boolean): Promise<void> {
|
|
124
|
+
if (this.foreground === value) return;
|
|
125
|
+
this.foreground = value;
|
|
126
|
+
if (value) {
|
|
127
|
+
// A turn was started here and is still in flight, but its streamer was
|
|
128
|
+
// finalized when we went background. Recreate it and let onUpdate feed
|
|
129
|
+
// the remaining chunks/thoughts/tools just like a normal live turn — we
|
|
130
|
+
// own the agent's session/update events, so no tail-watch is needed.
|
|
131
|
+
if (this.busy && !this.streamer) {
|
|
132
|
+
// Any transient follow-watch of this session is now superseded.
|
|
133
|
+
if (this.watchIsFollow) this.stopWatch();
|
|
134
|
+
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs);
|
|
135
|
+
this.typing.start();
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
this.typing.stop();
|
|
139
|
+
this.stopWatch();
|
|
140
|
+
if (this.streamer) {
|
|
141
|
+
await this.streamer.finalize().catch(() => {});
|
|
142
|
+
this.streamer = undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.changed();
|
|
146
|
+
}
|
|
147
|
+
get reasoning(): ReasoningEffort {
|
|
148
|
+
return this.settings.get(this.chatId).reasoning;
|
|
149
|
+
}
|
|
150
|
+
get agent(): string | undefined {
|
|
151
|
+
return this.settings.get(this.chatId).agent;
|
|
152
|
+
}
|
|
153
|
+
get model(): string | undefined {
|
|
154
|
+
return this.settings.get(this.chatId).model;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Latest context-usage % / effort for the current session. */
|
|
158
|
+
contextInfo(): { contextUsagePercentage?: number; effort?: string } | undefined {
|
|
159
|
+
return this.acp.metadataFor(this.sessionId);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
dispose(): void {
|
|
163
|
+
this.acp.off("session-update", this.listener);
|
|
164
|
+
this.acp.off("restarted", this.restartListener);
|
|
165
|
+
this.typing.stop();
|
|
166
|
+
this.stopWatch();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── sessions ───────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
async startNewSession(cwd: string, projectName?: string): Promise<void> {
|
|
172
|
+
if (this.busy) await this.cancel();
|
|
173
|
+
this.stopWatch();
|
|
174
|
+
this.sessionId = await this.acp.newSession(cwd);
|
|
175
|
+
this.sessionLive = true;
|
|
176
|
+
this.rebindPending = false;
|
|
177
|
+
this.cwd = cwd;
|
|
178
|
+
this.projectName = projectName;
|
|
179
|
+
await this.applySessionPrefs();
|
|
180
|
+
this.persist();
|
|
181
|
+
log.info(`chat ${this.chatId} -> new session ${this.sessionId} @ ${cwd}`);
|
|
182
|
+
this.changed();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Ensure a session is live in the current ACP process (used before menus). */
|
|
186
|
+
async prepare(): Promise<void> {
|
|
187
|
+
await this.ensureSession();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async resumeSession(sessionId: string, cwd: string, projectName?: string): Promise<void> {
|
|
191
|
+
if (!this.acp.supportsLoadSession) {
|
|
192
|
+
throw new Error("This Kiro CLI build does not support loading sessions.");
|
|
193
|
+
}
|
|
194
|
+
if (this.busy) await this.cancel();
|
|
195
|
+
this.stopWatch();
|
|
196
|
+
await this.acp.loadSession(sessionId, cwd);
|
|
197
|
+
this.sessionId = sessionId;
|
|
198
|
+
this.sessionLive = true;
|
|
199
|
+
this.rebindPending = false;
|
|
200
|
+
this.cwd = cwd;
|
|
201
|
+
this.projectName = projectName;
|
|
202
|
+
this.persist();
|
|
203
|
+
log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
|
|
204
|
+
this.changed();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async attach(
|
|
208
|
+
sessionId: string,
|
|
209
|
+
cwd: string,
|
|
210
|
+
projectName: string | undefined,
|
|
211
|
+
priorEntries: HistoryEntry[],
|
|
212
|
+
): Promise<AttachResult> {
|
|
213
|
+
try {
|
|
214
|
+
await this.resumeSession(sessionId, cwd, projectName);
|
|
215
|
+
return "resumed";
|
|
216
|
+
} catch (err) {
|
|
217
|
+
log.warn(`load failed (${(err as Error).message}); forking ${sessionId.slice(0, 8)}`);
|
|
218
|
+
await this.startNewSession(cwd, projectName);
|
|
219
|
+
if (priorEntries.length > 0) this.primingContext = buildPriming(buildTranscript(priorEntries));
|
|
220
|
+
return "forked";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
startWatch(jsonlPath: string, follow = false): void {
|
|
225
|
+
this.stopWatch();
|
|
226
|
+
this.watchIsFollow = follow;
|
|
227
|
+
this.watcher = new TailWatcher(jsonlPath, (entries) => void this.onWatchEntries(entries));
|
|
228
|
+
this.watcher.start(true);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
stopWatch(): boolean {
|
|
232
|
+
if (!this.watcher) return false;
|
|
233
|
+
this.watcher.stop();
|
|
234
|
+
this.watcher = undefined;
|
|
235
|
+
this.watchIsFollow = false;
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── preferences ──────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
|
|
242
|
+
// Persist the choice always; only talk to Kiro when a session is live in
|
|
243
|
+
// the current process (set_model on an unloaded session crashes the agent).
|
|
244
|
+
this.settings.update(this.chatId, { model: modelId });
|
|
245
|
+
if (modelId && this.sessionLive && this.sessionId) {
|
|
246
|
+
if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
|
|
247
|
+
try {
|
|
248
|
+
await this.acp.setModel(this.sessionId, modelId);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
this.changed();
|
|
251
|
+
return { ok: false, error: (e as Error).message };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
this.changed();
|
|
255
|
+
return { ok: true };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async setAgentPref(agent: string): Promise<void> {
|
|
259
|
+
this.settings.update(this.chatId, { agent });
|
|
260
|
+
if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
|
|
261
|
+
try {
|
|
262
|
+
await this.acp.setMode(this.sessionId, agent);
|
|
263
|
+
} catch (e) {
|
|
264
|
+
log.warn(`set_mode(${agent}) failed: ${(e as Error).message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
this.changed();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
setReasoningPref(effort: ReasoningEffort): void {
|
|
271
|
+
this.settings.update(this.chatId, { reasoning: effort });
|
|
272
|
+
this.changed();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private async applySessionPrefs(): Promise<void> {
|
|
276
|
+
const s = this.settings.get(this.chatId);
|
|
277
|
+
// Drop any persisted model the agent doesn't actually offer (an unknown id
|
|
278
|
+
// is silently accepted by set_model but then breaks the next prompt).
|
|
279
|
+
if (s.model && !this.acp.hasModel(s.model)) {
|
|
280
|
+
log.warn(`clearing invalid persisted model "${s.model}" for chat ${this.chatId}`);
|
|
281
|
+
this.settings.update(this.chatId, { model: "" });
|
|
282
|
+
}
|
|
283
|
+
const cur = this.settings.get(this.chatId);
|
|
284
|
+
// Adopt the session's current agent (mode) when the user hasn't chosen one.
|
|
285
|
+
if (!cur.agent && this.acp.currentModeId) {
|
|
286
|
+
this.settings.update(this.chatId, { agent: this.acp.currentModeId });
|
|
287
|
+
} else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
|
|
288
|
+
try {
|
|
289
|
+
await this.acp.setMode(this.sessionId, cur.agent);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
log.debug(`apply agent failed: ${(e as Error).message}`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (this.sessionId && cur.model && this.acp.hasModel(cur.model)) {
|
|
295
|
+
try {
|
|
296
|
+
await this.acp.setModel(this.sessionId, cur.model);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
log.debug(`apply model failed: ${(e as Error).message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── prompting ──────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
async submit(input: PromptInput): Promise<"ran" | "queued"> {
|
|
306
|
+
await this.ensureSession();
|
|
307
|
+
if (this.busy) {
|
|
308
|
+
this.queue.push(input);
|
|
309
|
+
this.changed();
|
|
310
|
+
return "queued";
|
|
311
|
+
}
|
|
312
|
+
void this.runTurn(input);
|
|
313
|
+
return "ran";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
enqueue(input: PromptInput): void {
|
|
317
|
+
this.queue.push(input);
|
|
318
|
+
this.changed();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async cancel(): Promise<boolean> {
|
|
322
|
+
if (!this.busy || !this.sessionId) return false;
|
|
323
|
+
this.cancelled = true;
|
|
324
|
+
await this.acp.cancel(this.sessionId);
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
clearQueue(): number {
|
|
329
|
+
const n = this.queue.length;
|
|
330
|
+
this.queue.length = 0;
|
|
331
|
+
this.changed();
|
|
332
|
+
return n;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
drainQueueToPrompt(): PromptInput | undefined {
|
|
336
|
+
if (this.queue.length === 0) return undefined;
|
|
337
|
+
return mergeInputs(this.queue.splice(0, this.queue.length));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private async ensureSession(): Promise<void> {
|
|
341
|
+
if (this.rebindPending && this.sessionId) {
|
|
342
|
+
// The ACP process is frequently mid-restart the first time we re-bind
|
|
343
|
+
// (auto-restart after a crash, or a fresh bot boot), so a single attempt
|
|
344
|
+
// is flaky. Retry briefly before giving up.
|
|
345
|
+
if (await this.rebindWithRetries(this.sessionId)) {
|
|
346
|
+
this.sessionLive = true;
|
|
347
|
+
this.rebindPending = false;
|
|
348
|
+
await this.applySessionPrefs();
|
|
349
|
+
log.info(`chat ${this.chatId} re-bound session ${this.sessionId.slice(0, 8)}`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// The session genuinely can't be reloaded (its exclusive lock is held,
|
|
353
|
+
// or its log/metadata is gone). Never silently drop the conversation:
|
|
354
|
+
// fork a linked continuation primed with the recent transcript so the
|
|
355
|
+
// thread survives — including any question the agent had just asked.
|
|
356
|
+
// forkFromLostSession() only throws if the agent is fully down, in which
|
|
357
|
+
// case we leave rebindPending set so the next message retries cleanly.
|
|
358
|
+
await this.forkFromLostSession(this.sessionId);
|
|
359
|
+
this.rebindPending = false;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
366
|
+
* Returns true once loaded, false after the attempts are exhausted. */
|
|
367
|
+
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
368
|
+
const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
|
|
369
|
+
for (let i = 0; i < attempts; i++) {
|
|
370
|
+
try {
|
|
371
|
+
await this.acp.loadSession(sessionId, this.cwd);
|
|
372
|
+
return true;
|
|
373
|
+
} catch (err) {
|
|
374
|
+
log.warn(
|
|
375
|
+
`re-bind ${sessionId.slice(0, 8)} attempt ${i + 1}/${attempts} failed: ${(err as Error).message}`,
|
|
376
|
+
);
|
|
377
|
+
if (i === attempts - 1) return false;
|
|
378
|
+
await sleep(delays[Math.min(i, delays.length - 1)]!);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Continue a session we could not reload by forking a fresh one primed with
|
|
385
|
+
* the lost session's recent transcript, so no context is dropped. */
|
|
386
|
+
private async forkFromLostSession(lostId: string): Promise<void> {
|
|
387
|
+
let transcript = "";
|
|
388
|
+
try {
|
|
389
|
+
const entries = readHistory(join(this.cfg.sessionsDir, `${lostId}.jsonl`), 24);
|
|
390
|
+
if (entries.length > 0) transcript = buildTranscript(entries);
|
|
391
|
+
} catch {
|
|
392
|
+
/* no recoverable history on disk */
|
|
393
|
+
}
|
|
394
|
+
log.warn(
|
|
395
|
+
`chat ${this.chatId} could not reload ${lostId.slice(0, 8)}; forking a linked continuation` +
|
|
396
|
+
(transcript ? " (primed with recent transcript)" : ""),
|
|
397
|
+
);
|
|
398
|
+
await this.startNewSession(this.cwd, this.projectName); // sets a fresh, live sessionId
|
|
399
|
+
if (transcript) this.primingContext = buildPriming(transcript);
|
|
400
|
+
if (this.foreground) {
|
|
401
|
+
await this.notify(
|
|
402
|
+
transcript
|
|
403
|
+
? "\u{1F517} Couldn't reopen the previous session, so I started a linked continuation primed with the recent transcript \u2014 we can keep going from where we left off."
|
|
404
|
+
: "\u{1F517} Couldn't reopen the previous session, so I started a fresh one here.",
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async runTurn(input: PromptInput): Promise<void> {
|
|
410
|
+
this.busy = true;
|
|
411
|
+
this.cancelled = false;
|
|
412
|
+
this.shownToolIds = new Set();
|
|
413
|
+
this.subagentShown = new Map();
|
|
414
|
+
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
415
|
+
// session's previous in-flight turn (avoids duplicated output).
|
|
416
|
+
if (this.watchIsFollow) this.stopWatch();
|
|
417
|
+
const live = this.foreground;
|
|
418
|
+
this.streamer = live ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs) : undefined;
|
|
419
|
+
if (live) this.typing.start();
|
|
420
|
+
this.activity(true);
|
|
421
|
+
this.changed();
|
|
422
|
+
const startedAt = Date.now();
|
|
423
|
+
this.turnStartedAt = startedAt;
|
|
424
|
+
this.imageScanText = "";
|
|
425
|
+
this.sentImagesThisTurn = new Set();
|
|
426
|
+
|
|
427
|
+
const content = buildContentBlocks(input, {
|
|
428
|
+
reasoning: reasoningDirective(this.reasoning),
|
|
429
|
+
priming: this.primingContext,
|
|
430
|
+
});
|
|
431
|
+
this.primingContext = undefined;
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const outcome = await this.runPromptWithRetries(content);
|
|
435
|
+
if (this.streamer) await this.streamer.finalize();
|
|
436
|
+
if (this.foreground) {
|
|
437
|
+
await this.sendTurnImages();
|
|
438
|
+
if (outcome.result || this.cancelled) {
|
|
439
|
+
await this.notify(this.completionLine(outcome.result?.stopReason, startedAt), { loud: true });
|
|
440
|
+
} else if (outcome.error) {
|
|
441
|
+
const transient = isTransientAcpError(outcome.error);
|
|
442
|
+
await this.notify(
|
|
443
|
+
formatErrorSummary(outcome.error, fmtDuration(Date.now() - startedAt), outcome.attempts, transient),
|
|
444
|
+
{ loud: true },
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
} catch (err) {
|
|
449
|
+
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
450
|
+
await this.streamer?.finalize().catch(() => {});
|
|
451
|
+
if (this.foreground) {
|
|
452
|
+
await this.notify(`\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${(err as Error).message}`, {
|
|
453
|
+
loud: true,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
} finally {
|
|
457
|
+
this.typing.stop();
|
|
458
|
+
this.streamer = undefined;
|
|
459
|
+
this.busy = false;
|
|
460
|
+
this.activity(false);
|
|
461
|
+
// The in-flight turn we may have been following live is over.
|
|
462
|
+
if (this.watchIsFollow) this.stopWatch();
|
|
463
|
+
this.changed();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
await this.flushQueue();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
private activity(busy: boolean): void {
|
|
470
|
+
try {
|
|
471
|
+
this.onActivity?.(busy);
|
|
472
|
+
} catch {
|
|
473
|
+
/* non-fatal */
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Show subagent ("crew") status transitions for the given (already
|
|
479
|
+
* chat-attributed) subagents, so the user sees progress while the main agent
|
|
480
|
+
* waits on them. No-op unless this runtime is the live foreground turn.
|
|
481
|
+
*/
|
|
482
|
+
renderSubagents(subagents: SubagentInfo[], _pending: PendingStage[]): void {
|
|
483
|
+
if (!this.cfg.showSubagents) return;
|
|
484
|
+
if (!this.foreground || !this.busy || !this.streamer) return;
|
|
485
|
+
for (const s of subagents) {
|
|
486
|
+
const key = statusKey(s);
|
|
487
|
+
const prev = this.subagentShown.get(s.sessionId);
|
|
488
|
+
if (prev === key) continue;
|
|
489
|
+
const kind: "start" | "status" = prev === undefined && isActiveStatus(key) ? "start" : "status";
|
|
490
|
+
this.subagentShown.set(s.sessionId, key);
|
|
491
|
+
const md = renderSubagentTransition(s, kind);
|
|
492
|
+
if (md) this.streamer.addTool(md);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Run the prompt, retrying *transient* agent errors (e.g. "high volume of
|
|
498
|
+
* traffic" / -32603) with an exponential backoff (6s → 12s → 24s → 48s → 60s,
|
|
499
|
+
* then give up). The real error is shown to the user on every failed attempt.
|
|
500
|
+
*
|
|
501
|
+
* We only retry while the turn has produced **no streamed output** (so tools
|
|
502
|
+
* aren't re-run and text isn't duplicated) and the user hasn't cancelled.
|
|
503
|
+
* Returns the result, or the last error once retries are exhausted.
|
|
504
|
+
*/
|
|
505
|
+
private async runPromptWithRetries(
|
|
506
|
+
content: ContentBlock[],
|
|
507
|
+
): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
|
|
508
|
+
const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
|
|
509
|
+
const totalAttempts = delays.length + 1;
|
|
510
|
+
let attempt = 0;
|
|
511
|
+
for (;;) {
|
|
512
|
+
attempt++;
|
|
513
|
+
try {
|
|
514
|
+
const result = await this.acp.prompt(this.sessionId!, content);
|
|
515
|
+
return { result, attempts: attempt };
|
|
516
|
+
} catch (err) {
|
|
517
|
+
const error = err as Error;
|
|
518
|
+
const willRetry =
|
|
519
|
+
attempt <= delays.length &&
|
|
520
|
+
!this.cancelled &&
|
|
521
|
+
!this.streamer?.hasOutput &&
|
|
522
|
+
isTransientAcpError(error);
|
|
523
|
+
if (!willRetry) return { error, attempts: attempt };
|
|
524
|
+
const waitMs = delays[attempt - 1]!;
|
|
525
|
+
if (this.foreground) await this.notify(formatRetryNotice(error, attempt + 1, totalAttempts, waitMs));
|
|
526
|
+
if (await this.interruptibleSleep(waitMs)) return { error, attempts: attempt };
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Sleep that returns true early if the user cancels the turn meanwhile. */
|
|
532
|
+
private async interruptibleSleep(ms: number): Promise<boolean> {
|
|
533
|
+
const step = 500;
|
|
534
|
+
for (let waited = 0; waited < ms; waited += step) {
|
|
535
|
+
if (this.cancelled) return true;
|
|
536
|
+
await sleep(Math.min(step, ms - waited));
|
|
537
|
+
}
|
|
538
|
+
return this.cancelled;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Send any fresh images the agent produced this turn (screenshots, etc.). */
|
|
542
|
+
private async sendTurnImages(): Promise<void> {
|
|
543
|
+
if (!this.cfg.sendAgentImages || !this.imageScanText) return;
|
|
544
|
+
const paths = extractImagePaths(this.imageScanText, this.cwd);
|
|
545
|
+
if (paths.length === 0) return;
|
|
546
|
+
try {
|
|
547
|
+
await sendImages(this.api, this.chatId, paths, {
|
|
548
|
+
since: this.turnStartedAt,
|
|
549
|
+
already: this.sentImagesThisTurn,
|
|
550
|
+
max: this.cfg.agentImagesMax,
|
|
551
|
+
});
|
|
552
|
+
} catch {
|
|
553
|
+
/* non-fatal */
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Build the "turn finished" line shown after every turn. */
|
|
558
|
+
private completionLine(stopReason: string | undefined, startedAt: number): string {
|
|
559
|
+
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
560
|
+
if (this.cancelled || stopReason === "cancelled") {
|
|
561
|
+
return `\u23F9 Stopped \u00B7 ${elapsed}`;
|
|
562
|
+
}
|
|
563
|
+
const reason = stopReason || "end_turn";
|
|
564
|
+
const ctx = this.contextInfo()?.contextUsagePercentage;
|
|
565
|
+
const ctxStr = ctx !== undefined ? ` \u00B7 ctx ${ctx.toFixed(0)}%` : "";
|
|
566
|
+
const noOut = this.streamer?.hasOutput ? "" : " \u00B7 no text output";
|
|
567
|
+
return `\u2705 Done \u00B7 ${reason} \u00B7 ${elapsed}${ctxStr}${noOut}`;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async flushQueue(): Promise<void> {
|
|
571
|
+
if (this.queue.length === 0 || this.busy) return;
|
|
572
|
+
const batch = mergeInputs(this.queue.splice(0, this.queue.length));
|
|
573
|
+
if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
|
|
574
|
+
void this.runTurn(batch);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
private onUpdate(sessionId: string, update: SessionUpdate): void {
|
|
578
|
+
if (!this.busy || !this.foreground || sessionId !== this.sessionId || !this.streamer) return;
|
|
579
|
+
const kind = update.sessionUpdate;
|
|
580
|
+
if (kind === "agent_message_chunk") {
|
|
581
|
+
const text = update.content?.text;
|
|
582
|
+
if (typeof text === "string") {
|
|
583
|
+
this.streamer.appendOutput(text);
|
|
584
|
+
this.imageScanText += text;
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (kind === "agent_thought_chunk") {
|
|
589
|
+
const text = update.content?.text;
|
|
590
|
+
if (typeof text === "string") this.streamer.appendThought(text);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
594
|
+
if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
|
|
595
|
+
if (update.title) this.imageScanText += " " + update.title;
|
|
596
|
+
if (!this.cfg.showToolCalls) return;
|
|
597
|
+
const id = update.toolCallId || `${kind}:${update.title ?? ""}`;
|
|
598
|
+
if (this.shownToolIds.has(id)) return;
|
|
599
|
+
this.shownToolIds.add(id);
|
|
600
|
+
const md = formatToolCall(update, {
|
|
601
|
+
showDiffs: this.cfg.showEditDiffs,
|
|
602
|
+
diffMaxLines: this.cfg.diffMaxLines,
|
|
603
|
+
});
|
|
604
|
+
if (md) this.streamer.addTool(md);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private persist(): void {
|
|
609
|
+
if (!this.foreground) return; // only the foreground session is the chat's restored default
|
|
610
|
+
this.settings.update(this.chatId, {
|
|
611
|
+
projectPath: this.cwd,
|
|
612
|
+
projectName: this.projectName,
|
|
613
|
+
sessionId: this.sessionId,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private changed(): void {
|
|
618
|
+
try {
|
|
619
|
+
this.onStateChange?.();
|
|
620
|
+
} catch {
|
|
621
|
+
/* non-fatal */
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private async notify(text: string, opts?: { loud?: boolean }): Promise<void> {
|
|
626
|
+
try {
|
|
627
|
+
await this.api.sendMessage(this.chatId, text, opts?.loud ? { disable_notification: false } : {});
|
|
628
|
+
} catch {
|
|
629
|
+
/* non-fatal */
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
|
|
634
|
+
const body = entries
|
|
635
|
+
.map((e) => {
|
|
636
|
+
const icon = WATCH_ICON[e.role] ?? "\u2022";
|
|
637
|
+
if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
|
|
638
|
+
const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " …" : e.text;
|
|
639
|
+
return `${icon} ${text}`;
|
|
640
|
+
})
|
|
641
|
+
.filter(Boolean)
|
|
642
|
+
.join("\n\n");
|
|
643
|
+
if (body.trim()) await sendMarkdownDoc(this.api, this.chatId, body);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */
|
|
648
|
+
function fmtDuration(ms: number): string {
|
|
649
|
+
const s = Math.round(ms / 1000);
|
|
650
|
+
if (s < 60) return `${s}s`;
|
|
651
|
+
const m = Math.floor(s / 60);
|
|
652
|
+
if (m < 60) return `${m}m ${s % 60}s`;
|
|
653
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
function buildPriming(transcript: string): string {
|
|
658
|
+
return [
|
|
659
|
+
"You are resuming a conversation that is currently still running in another",
|
|
660
|
+
"window on this machine, so this is a linked continuation. Below is the recent",
|
|
661
|
+
"transcript for context — use it to continue seamlessly.",
|
|
662
|
+
"",
|
|
663
|
+
"=== RECENT TRANSCRIPT ===",
|
|
664
|
+
transcript,
|
|
665
|
+
"=== END TRANSCRIPT ===",
|
|
666
|
+
].join("\n");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Convenience for callers that only have text. */
|
|
670
|
+
export { textPrompt };
|