grok-telegram-bot 2.3.0 → 2.4.0
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 +26 -0
- package/CHANGELOG.md +55 -0
- package/package.json +1 -1
- package/scripts/analyze-jsonl.ts +33 -0
- package/scripts/delayed-restart.ps1 +29 -0
- package/scripts/probe-exit-response-shape.py +77 -0
- package/scripts/probe-plan-exit.py +60 -0
- package/scripts/probe-plan-exit2.py +48 -0
- package/scripts/probe-plan-fields.py +41 -0
- package/scripts/probe-plan-fields2.py +58 -0
- package/scripts/probe-plan-response-path.py +48 -0
- package/scripts/sample-claude-tooluse.ts +21 -0
- package/scripts/sample-kiro-events.ts +31 -0
- package/scripts/smoke-exit-plan.ts +274 -0
- package/scripts/smoke-exit-shapes.ts +252 -0
- package/scripts/smoke-import.mjs +82 -0
- package/scripts/smoke-import.ts +73 -0
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/types.ts +19 -2
- package/src/app/updater.ts +17 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +71 -2
- package/src/bot/bot.ts +36 -0
- package/src/bot/chat-controller.ts +35 -0
- package/src/bot/commands.ts +2 -0
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +19 -0
- package/src/bot/handlers/accounts.ts +55 -5
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +17 -38
- package/src/bot/handlers/message.ts +1 -0
- package/src/bot/handlers/running.ts +35 -5
- package/src/bot/handlers/session-card.ts +12 -0
- package/src/bot/handlers/sessions.ts +14 -3
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/menu/keyboard.ts +5 -4
- package/src/bot/menu/status-panel.ts +19 -6
- package/src/bot/prompt-content.ts +4 -0
- package/src/bot/reauth-controller.ts +2 -2
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +831 -64
- package/src/bot/suggestions.ts +429 -0
- package/src/config.ts +41 -0
- package/src/grok/client.ts +106 -20
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +179 -24
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +261 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +405 -142
- package/src/render/truncate.ts +85 -0
- package/src/service/windows.ts +14 -2
- package/src/sessions/history.ts +57 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +73 -9
- package/src/tasks/runner.ts +4 -3
|
@@ -4,36 +4,78 @@
|
|
|
4
4
|
* and per-chat preferences (project, agent, model, reasoning). State persists
|
|
5
5
|
* to the settings store so it survives restarts.
|
|
6
6
|
*/
|
|
7
|
-
import { basename } from "node:path";
|
|
7
|
+
import { basename, join } from "node:path";
|
|
8
8
|
import { type Api, InlineKeyboard } from "grammy";
|
|
9
9
|
import {
|
|
10
10
|
type GrokClient,
|
|
11
11
|
isAccountRotationError,
|
|
12
12
|
isContextExhaustedError,
|
|
13
|
+
isSessionLifecycleError,
|
|
13
14
|
isTransientError,
|
|
14
15
|
type SessionMetadata,
|
|
15
16
|
} from "../grok/client.js";
|
|
16
17
|
import type { AccountRotator } from "./account-rotator.js";
|
|
17
|
-
import type
|
|
18
|
+
import { contentText, type ContentBlock, type PromptResult, type SessionUpdate } from "../grok/types.js";
|
|
18
19
|
import type { AppConfig } from "../config.js";
|
|
19
20
|
import { reasoningDirective } from "../app/reasoning.js";
|
|
20
21
|
import type { SettingsStore } from "../app/settings-store.js";
|
|
21
22
|
import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
|
|
22
23
|
import { createLogger } from "../logger.js";
|
|
23
|
-
import { buildTranscript } from "../sessions/history.js";
|
|
24
|
+
import { buildTranscript, readHistory } from "../sessions/history.js";
|
|
24
25
|
import { sessionHashtags } from "../render/hashtags.js";
|
|
25
26
|
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
26
27
|
import { buildPriming, recentTranscript } from "./session-fork.js";
|
|
27
28
|
import { TailWatcher } from "../sessions/tail.js";
|
|
28
29
|
import type { HistoryEntry } from "../sessions/types.js";
|
|
29
30
|
import { formatToolCall } from "../render/tool-call.js";
|
|
30
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
mergeToolSnapshot,
|
|
33
|
+
snapshotHasDetail,
|
|
34
|
+
type ToolSnapshot,
|
|
35
|
+
} from "../render/tool-call-merge.js";
|
|
36
|
+
import {
|
|
37
|
+
type FileOp,
|
|
38
|
+
cloneFileOps,
|
|
39
|
+
fileOpFromUpdate,
|
|
40
|
+
mergeFileOp,
|
|
41
|
+
summarizeFileOps,
|
|
42
|
+
summarizeFileOpsShort,
|
|
43
|
+
summarizeFileOpsSplit,
|
|
44
|
+
} from "../render/file-summary.js";
|
|
31
45
|
import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
|
|
32
46
|
import type { PendingStage, SubagentInfo } from "../grok/types.js";
|
|
33
47
|
import { ResponseStreamer } from "../stream/streamer.js";
|
|
34
48
|
import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
|
|
35
49
|
import { collectTurnImagePaths, sendImages } from "./image-return.js";
|
|
36
50
|
import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
|
|
51
|
+
import { wrapAutoComplexityPrompt } from "./complexity-gate.js";
|
|
52
|
+
import {
|
|
53
|
+
autoApproveSuggestions,
|
|
54
|
+
buildSelfRecheckDecisionPrompt,
|
|
55
|
+
buildSelfRecheckPrompt,
|
|
56
|
+
buildSuggestionsPrompt,
|
|
57
|
+
composeSelfRecheckTurn,
|
|
58
|
+
formatBatchedSuggestionsPrompt,
|
|
59
|
+
isSelfRecheckPrompt,
|
|
60
|
+
parseSelfRecheckDecision,
|
|
61
|
+
parseSuggestions,
|
|
62
|
+
type Suggestion,
|
|
63
|
+
suggestionsKeyboard,
|
|
64
|
+
} from "./suggestions.js";
|
|
65
|
+
import {
|
|
66
|
+
parsePlanUpdate,
|
|
67
|
+
renderPlanMarkdown,
|
|
68
|
+
renderPlanOneLine,
|
|
69
|
+
type PlanEntry,
|
|
70
|
+
} from "../render/plan.js";
|
|
71
|
+
import {
|
|
72
|
+
buildLastTurnSummary,
|
|
73
|
+
cleanCommentLine,
|
|
74
|
+
cleanUserPreview,
|
|
75
|
+
stepFromThought,
|
|
76
|
+
stepFromToolUpdate,
|
|
77
|
+
stripDirectiveWrappers,
|
|
78
|
+
} from "../render/session-comment.js";
|
|
37
79
|
import {
|
|
38
80
|
backoffSchedule,
|
|
39
81
|
fmtSeconds,
|
|
@@ -84,6 +126,8 @@ export class SessionRuntime {
|
|
|
84
126
|
private streamer: ResponseStreamer | undefined;
|
|
85
127
|
private readonly typing: TypingIndicator;
|
|
86
128
|
private shownToolIds = new Set<string>();
|
|
129
|
+
/** toolCallId → merged snapshot so completed updates keep title/args. */
|
|
130
|
+
private toolCallCache = new Map<string, ToolSnapshot>();
|
|
87
131
|
/** Files touched this turn (path -> operation), tracked even in background so
|
|
88
132
|
* the completion message can summarise what changed. */
|
|
89
133
|
private fileOps = new Map<string, FileOp>();
|
|
@@ -126,6 +170,49 @@ export class SessionRuntime {
|
|
|
126
170
|
/** Optional multi-account rotator: when a turn gives up, cycle through the
|
|
127
171
|
* other saved logins once and retry on each. Injected by the registry. */
|
|
128
172
|
accountRotator: AccountRotator | undefined;
|
|
173
|
+
/** Session ids that already received the first-prompt auto-complexity directive. */
|
|
174
|
+
private complexitySteered = new Set<string>();
|
|
175
|
+
/** Last credits total reported for this session (for per-turn delta accounting). */
|
|
176
|
+
private lastReportedCredits = 0;
|
|
177
|
+
/** Live "what is happening now" line while a turn is in flight. */
|
|
178
|
+
private liveStep: string | undefined;
|
|
179
|
+
/** Idle card comment (AI/local summary of the chat after the last turn). */
|
|
180
|
+
private sessionComment: string | undefined;
|
|
181
|
+
/** User text of the turn currently running (for local card-comment fallback). */
|
|
182
|
+
private turnUserText = "";
|
|
183
|
+
/** Assistant prose streamed this turn — used to build the idle card summary. */
|
|
184
|
+
private turnAssistantText = "";
|
|
185
|
+
/** Quiet meta capture (suggestions) — never stream to Telegram. */
|
|
186
|
+
private capturingQuiet = false;
|
|
187
|
+
private quietCaptureBuf = "";
|
|
188
|
+
/** Batches of post-turn suggestions for inline-button callbacks. */
|
|
189
|
+
private suggestionBatches = new Map<number, Suggestion[]>();
|
|
190
|
+
private suggestionBatchSeq = 0;
|
|
191
|
+
/**
|
|
192
|
+
* Last successful Done's suggestions — kept so a background "Done from other
|
|
193
|
+
* session" can carry buttons, and so switching back to this session re-shows
|
|
194
|
+
* them even if the user missed the notify (or notify was off).
|
|
195
|
+
*/
|
|
196
|
+
private pendingSuggestions:
|
|
197
|
+
| { batchId: number; suggestions: Suggestion[]; banner: string }
|
|
198
|
+
| undefined;
|
|
199
|
+
/** Live ACP plan board for the current turn (done / in-progress / pending). */
|
|
200
|
+
private planEntries: PlanEntry[] | undefined;
|
|
201
|
+
/** True while the active turn is the automatic one-shot self-recheck pass. */
|
|
202
|
+
private isSelfRecheckTurn = false;
|
|
203
|
+
/**
|
|
204
|
+
* Original user prompt (and first-pass assistant text) for suggestions after
|
|
205
|
+
* a self-recheck turn, so follow-ups stay grounded in the real user request.
|
|
206
|
+
*/
|
|
207
|
+
private suggestionUserText = "";
|
|
208
|
+
private preRecheckAssistantText = "";
|
|
209
|
+
/** File ops from the first turn, frozen before the self-recheck pass. */
|
|
210
|
+
private preRecheckFileOps = new Map<string, FileOp>();
|
|
211
|
+
/**
|
|
212
|
+
* When true, this turn must not schedule a self-recheck (meta / auto-queue /
|
|
213
|
+
* already-recheck). Set from PromptInput.skipSelfRecheck or recheck marker.
|
|
214
|
+
*/
|
|
215
|
+
private skipSelfRecheck = false;
|
|
129
216
|
|
|
130
217
|
constructor(
|
|
131
218
|
private readonly api: Api,
|
|
@@ -175,11 +262,53 @@ export class SessionRuntime {
|
|
|
175
262
|
return this.lastCompletion;
|
|
176
263
|
}
|
|
177
264
|
|
|
178
|
-
/** Latest task-completion % (0
|
|
265
|
+
/** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
|
|
179
266
|
get taskProgress(): number | undefined {
|
|
180
267
|
return this.progress;
|
|
181
268
|
}
|
|
182
269
|
|
|
270
|
+
/**
|
|
271
|
+
* Full plan board for the live stream / status panel (above the progress bar).
|
|
272
|
+
* Empty when no plan is active this turn.
|
|
273
|
+
*/
|
|
274
|
+
get planBoard(): string | undefined {
|
|
275
|
+
if (!this.planEntries?.length) return undefined;
|
|
276
|
+
return renderPlanMarkdown(this.planEntries);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One-line plan summary for compact cards. */
|
|
280
|
+
get planSummary(): string | undefined {
|
|
281
|
+
if (!this.planEntries?.length) return undefined;
|
|
282
|
+
return renderPlanOneLine(this.planEntries);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Pending post-turn suggestions for switch replay / Done markup.
|
|
287
|
+
* Returns text + keyboard without clearing (taps still resolve via batch id).
|
|
288
|
+
*/
|
|
289
|
+
peekPendingSuggestions():
|
|
290
|
+
| { text: string; markup: InlineKeyboard; batchId: number }
|
|
291
|
+
| undefined {
|
|
292
|
+
const p = this.pendingSuggestions;
|
|
293
|
+
if (!p?.suggestions.length) return undefined;
|
|
294
|
+
return {
|
|
295
|
+
text: p.banner,
|
|
296
|
+
markup: suggestionsKeyboard(p.batchId, p.suggestions),
|
|
297
|
+
batchId: p.batchId,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One-line status for Running/Sessions cards:
|
|
303
|
+
* live step while busy, otherwise the last chat summary / comment.
|
|
304
|
+
*/
|
|
305
|
+
get cardComment(): string | undefined {
|
|
306
|
+
if (this.busy && this.liveStep) return this.liveStep;
|
|
307
|
+
if (this.sessionComment) return this.sessionComment;
|
|
308
|
+
if (this.sessionId) return this.acp.sessionComment(this.sessionId);
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
183
312
|
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
184
313
|
* is monotonic within a turn (it's reset to undefined when a new turn starts),
|
|
185
314
|
* so a streamer recreated mid-turn can't make it jump backwards. */
|
|
@@ -190,6 +319,36 @@ export class SessionRuntime {
|
|
|
190
319
|
this.changed();
|
|
191
320
|
}
|
|
192
321
|
|
|
322
|
+
/** Update the live step shown on session cards (throttled by equality). */
|
|
323
|
+
private setLiveStep(step: string | undefined): void {
|
|
324
|
+
const next = step?.trim() ? cleanCommentLine(step) : undefined;
|
|
325
|
+
if (next === this.liveStep) return;
|
|
326
|
+
this.liveStep = next;
|
|
327
|
+
this.changed();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Persist idle card comment (disk + memory) so /running and /sessions see it. */
|
|
331
|
+
private setSessionComment(comment: string | undefined): void {
|
|
332
|
+
const next = comment?.trim() ? cleanCommentLine(comment) : undefined;
|
|
333
|
+
if (next === this.sessionComment) return;
|
|
334
|
+
this.sessionComment = next;
|
|
335
|
+
if (next && this.sessionId) {
|
|
336
|
+
try {
|
|
337
|
+
this.acp.setSessionComment(this.sessionId, next);
|
|
338
|
+
} catch {
|
|
339
|
+
/* non-fatal */
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
this.changed();
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Hydrate comment from disk after bind/resume. */
|
|
346
|
+
private loadPersistedComment(): void {
|
|
347
|
+
if (!this.sessionId) return;
|
|
348
|
+
const c = this.acp.sessionComment(this.sessionId);
|
|
349
|
+
if (c) this.sessionComment = c;
|
|
350
|
+
}
|
|
351
|
+
|
|
193
352
|
/** Searchable hashtag footer for this session (project В· session В· model В·
|
|
194
353
|
* reasoning) — appended to every AI-output surface for this session. */
|
|
195
354
|
get tags(): string {
|
|
@@ -211,6 +370,10 @@ export class SessionRuntime {
|
|
|
211
370
|
// Any transient follow-watch of this session is now superseded.
|
|
212
371
|
if (this.watchIsFollow) this.stopWatch();
|
|
213
372
|
this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
|
|
373
|
+
// Restore the live plan board so steps stay visible above the progress bar.
|
|
374
|
+
if (this.planEntries?.length) {
|
|
375
|
+
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
376
|
+
}
|
|
214
377
|
this.typing.start();
|
|
215
378
|
}
|
|
216
379
|
} else {
|
|
@@ -256,6 +419,7 @@ export class SessionRuntime {
|
|
|
256
419
|
// в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
|
|
257
420
|
|
|
258
421
|
async startNewSession(cwd: string, projectName?: string): Promise<void> {
|
|
422
|
+
await this.accountRotator?.waitForIdle();
|
|
259
423
|
if (this.busy) await this.cancel();
|
|
260
424
|
await this.bindNewSession(cwd, projectName);
|
|
261
425
|
}
|
|
@@ -272,6 +436,10 @@ export class SessionRuntime {
|
|
|
272
436
|
this.rebindPending = false;
|
|
273
437
|
this.cwd = cwd;
|
|
274
438
|
this.projectName = projectName;
|
|
439
|
+
this.turnCount = 0;
|
|
440
|
+
this.lastReportedCredits = 0;
|
|
441
|
+
this.liveStep = undefined;
|
|
442
|
+
this.sessionComment = undefined;
|
|
275
443
|
await this.applySessionPrefs();
|
|
276
444
|
this.persist();
|
|
277
445
|
this.sessionChanged();
|
|
@@ -296,6 +464,7 @@ export class SessionRuntime {
|
|
|
296
464
|
this.rebindPending = false;
|
|
297
465
|
this.cwd = cwd;
|
|
298
466
|
this.projectName = projectName;
|
|
467
|
+
this.loadPersistedComment();
|
|
299
468
|
this.persist();
|
|
300
469
|
log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
|
|
301
470
|
this.changed();
|
|
@@ -318,6 +487,18 @@ export class SessionRuntime {
|
|
|
318
487
|
}
|
|
319
488
|
}
|
|
320
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Start a brand-new Grok session primed with a full foreign transcript
|
|
492
|
+
* (import from Kiro / OpenCode / Claude / Codex). Priming is applied on the
|
|
493
|
+
* next {@link submit} so the imported context becomes part of Grok's history.
|
|
494
|
+
*/
|
|
495
|
+
async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
|
|
496
|
+
await this.startNewSession(cwd, projectName);
|
|
497
|
+
if (priming.trim()) this.primingContext = priming;
|
|
498
|
+
// Imported transcripts already have context — skip first-prompt complexity steering.
|
|
499
|
+
this.markComplexitySteered();
|
|
500
|
+
}
|
|
501
|
+
|
|
321
502
|
startWatch(jsonlPath: string, follow = false): void {
|
|
322
503
|
this.stopWatch();
|
|
323
504
|
this.watchIsFollow = follow;
|
|
@@ -397,7 +578,7 @@ export class SessionRuntime {
|
|
|
397
578
|
}
|
|
398
579
|
}
|
|
399
580
|
|
|
400
|
-
//
|
|
581
|
+
// ── prompting ────────────────────────────────────────────────────────────
|
|
401
582
|
|
|
402
583
|
async submit(input: PromptInput): Promise<"ran" | "queued"> {
|
|
403
584
|
await this.ensureSession();
|
|
@@ -406,10 +587,43 @@ export class SessionRuntime {
|
|
|
406
587
|
this.changed();
|
|
407
588
|
return "queued";
|
|
408
589
|
}
|
|
409
|
-
|
|
590
|
+
// First prompt of a fresh session: steer the agent to decide complexity
|
|
591
|
+
// itself (plan if complex, implement if simple) — never ask the user.
|
|
592
|
+
let toRun = input;
|
|
593
|
+
if (this.shouldSteerComplexity()) {
|
|
594
|
+
toRun = wrapAutoComplexityPrompt(input);
|
|
595
|
+
this.markComplexitySteered();
|
|
596
|
+
log.info(`chat ${this.chatId}: first-prompt auto-complexity steering applied`);
|
|
597
|
+
}
|
|
598
|
+
void this.runTurn(toRun);
|
|
410
599
|
return "ran";
|
|
411
600
|
}
|
|
412
601
|
|
|
602
|
+
private markComplexitySteered(): void {
|
|
603
|
+
if (this.sessionId) this.complexitySteered.add(this.sessionId);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Apply auto-complexity directive only on the first prompt of a brand-new
|
|
608
|
+
* conversation (no prior user turns in this process / session jsonl).
|
|
609
|
+
*/
|
|
610
|
+
private shouldSteerComplexity(): boolean {
|
|
611
|
+
if (!this.sessionId) return false;
|
|
612
|
+
if (this.complexitySteered.has(this.sessionId)) return false;
|
|
613
|
+
if (this.turnCount > 0) return false;
|
|
614
|
+
try {
|
|
615
|
+
const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
|
|
616
|
+
const hist = readHistory(path, 8);
|
|
617
|
+
if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
|
|
618
|
+
this.complexitySteered.add(this.sessionId);
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
/* treat as fresh */
|
|
623
|
+
}
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
|
|
413
627
|
async cancel(): Promise<boolean> {
|
|
414
628
|
if (!this.busy || !this.sessionId) return false;
|
|
415
629
|
this.cancelled = true;
|
|
@@ -430,6 +644,9 @@ export class SessionRuntime {
|
|
|
430
644
|
}
|
|
431
645
|
|
|
432
646
|
private async ensureSession(): Promise<void> {
|
|
647
|
+
// Account rotation restarts the process globally. Do not bind a new chat
|
|
648
|
+
// to a candidate account until the owner has finished probing it.
|
|
649
|
+
await this.accountRotator?.waitForIdle();
|
|
433
650
|
if (this.rebindPending && this.sessionId) {
|
|
434
651
|
// The ACP process is frequently mid-restart the first time we re-bind
|
|
435
652
|
// (auto-restart after a crash, or a fresh bot boot), so a single attempt
|
|
@@ -457,10 +674,11 @@ export class SessionRuntime {
|
|
|
457
674
|
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
458
675
|
* Returns true once loaded, false after the attempts are exhausted. */
|
|
459
676
|
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
460
|
-
const delays = [400, 1200, 3000]; //
|
|
677
|
+
const delays = [400, 1200, 3000]; // ~4.6s total before giving up
|
|
461
678
|
for (let i = 0; i < attempts; i++) {
|
|
462
679
|
try {
|
|
463
680
|
await this.acp.loadSession(sessionId, this.cwd);
|
|
681
|
+
this.loadPersistedComment();
|
|
464
682
|
return true;
|
|
465
683
|
} catch (err) {
|
|
466
684
|
log.warn(
|
|
@@ -496,10 +714,34 @@ export class SessionRuntime {
|
|
|
496
714
|
this.busy = true;
|
|
497
715
|
this.cancelled = false;
|
|
498
716
|
this.turnReplyTo = input.replyTo;
|
|
717
|
+
this.turnUserText = input.text;
|
|
718
|
+
this.turnAssistantText = "";
|
|
719
|
+
this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
|
|
720
|
+
// Meta turns (recheck, auto-suggestion batches) never arm another recheck.
|
|
721
|
+
this.skipSelfRecheck = !!input.skipSelfRecheck || this.isSelfRecheckTurn;
|
|
722
|
+
// Fresh user work resets suggestion anchors; recheck keeps the original ask.
|
|
723
|
+
// Strip complexity/reply wrappers so suggestions + recheck see the real ask.
|
|
724
|
+
if (!this.isSelfRecheckTurn) {
|
|
725
|
+
this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
|
|
726
|
+
this.preRecheckAssistantText = "";
|
|
727
|
+
this.preRecheckFileOps = new Map();
|
|
728
|
+
}
|
|
499
729
|
this.shownToolIds = new Set();
|
|
730
|
+
this.toolCallCache = new Map();
|
|
500
731
|
this.fileOps = new Map();
|
|
501
732
|
this.subagentShown = new Map();
|
|
502
733
|
this.progress = undefined; // a new turn = a new task; clear the old bar
|
|
734
|
+
this.planEntries = undefined; // plan board is per-turn
|
|
735
|
+
this.pendingSuggestions = undefined; // new work supersedes previous Done suggestions
|
|
736
|
+
this.setLiveStep(
|
|
737
|
+
this.isSelfRecheckTurn
|
|
738
|
+
? "Self-recheck: hunting bugs / incomplete logic\u2026"
|
|
739
|
+
: input.text.trim()
|
|
740
|
+
? `Working: ${cleanUserPreview(input.text, 110)}`
|
|
741
|
+
: input.images.length
|
|
742
|
+
? "Working on attached image(s)\u2026"
|
|
743
|
+
: "Working\u2026",
|
|
744
|
+
);
|
|
503
745
|
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
504
746
|
// session's previous in-flight turn (avoids duplicated output).
|
|
505
747
|
if (this.watchIsFollow) this.stopWatch();
|
|
@@ -525,8 +767,10 @@ export class SessionRuntime {
|
|
|
525
767
|
|
|
526
768
|
try {
|
|
527
769
|
const outcome = await this.runPromptWithRetries(content);
|
|
528
|
-
const
|
|
529
|
-
let final =
|
|
770
|
+
const rebound = await this.maybeRecoverAgentSession(input, outcome);
|
|
771
|
+
let final = rebound ?? outcome;
|
|
772
|
+
const recovered = await this.maybeAutoFork(input, final);
|
|
773
|
+
final = recovered ?? final;
|
|
530
774
|
// Last resort: if the turn still failed, rotate through other saved
|
|
531
775
|
// accounts (once) and retry on each until one works.
|
|
532
776
|
const rotated = await this.maybeRotateAccount(input, final);
|
|
@@ -538,7 +782,7 @@ export class SessionRuntime {
|
|
|
538
782
|
if (resumed) final = resumed;
|
|
539
783
|
const streamedOutput = this.streamer?.hasOutput ?? false;
|
|
540
784
|
// On a successful, non-cancelled turn, top the fallback bar up to 100 (a
|
|
541
|
-
// no-op when the agent reported its own progress
|
|
785
|
+
// no-op when the agent reported its own progress — its value is kept).
|
|
542
786
|
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
543
787
|
if (this.streamer) await this.streamer.finalize();
|
|
544
788
|
if (this.foreground) await this.sendTurnImages();
|
|
@@ -547,31 +791,219 @@ export class SessionRuntime {
|
|
|
547
791
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
548
792
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
549
793
|
// A background session about to run a queued follow-up shouldn't ping its
|
|
550
|
-
// interim "Done"
|
|
794
|
+
// interim "Done" — only the final, queue-empty turn announces completion.
|
|
551
795
|
const hasQueued = this.queue.length > 0;
|
|
552
796
|
const switchKb = this.switchKeyboard();
|
|
553
|
-
if (final.result && !this.cancelled)
|
|
797
|
+
if (final.result && !this.cancelled) {
|
|
798
|
+
this.turnCount++;
|
|
799
|
+
// Persist real per-account usage (turns + reported credits) for /accounts and /usage.
|
|
800
|
+
this.recordAccountUsage();
|
|
801
|
+
// Card comment: what this turn solved (assistant result + files) — no extra agent call.
|
|
802
|
+
this.setSessionComment(
|
|
803
|
+
buildLastTurnSummary({
|
|
804
|
+
userText: this.turnUserText,
|
|
805
|
+
assistantText: this.turnAssistantText,
|
|
806
|
+
fileOps: this.fileOps,
|
|
807
|
+
stopReason: final.result.stopReason,
|
|
808
|
+
}),
|
|
809
|
+
);
|
|
810
|
+
this.setLiveStep(undefined);
|
|
811
|
+
} else if (this.cancelled) {
|
|
812
|
+
this.setSessionComment(
|
|
813
|
+
buildLastTurnSummary({
|
|
814
|
+
userText: this.turnUserText,
|
|
815
|
+
assistantText: this.turnAssistantText,
|
|
816
|
+
fileOps: this.fileOps,
|
|
817
|
+
cancelled: true,
|
|
818
|
+
}),
|
|
819
|
+
);
|
|
820
|
+
this.setLiveStep(undefined);
|
|
821
|
+
} else if (final.error) {
|
|
822
|
+
this.setSessionComment(
|
|
823
|
+
buildLastTurnSummary({
|
|
824
|
+
userText: this.turnUserText,
|
|
825
|
+
assistantText: this.turnAssistantText,
|
|
826
|
+
fileOps: this.fileOps,
|
|
827
|
+
error: final.error.message,
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
830
|
+
this.setLiveStep(undefined);
|
|
831
|
+
}
|
|
554
832
|
if (final.result || this.cancelled) {
|
|
555
|
-
const
|
|
833
|
+
const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
556
834
|
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
557
|
-
|
|
835
|
+
|
|
836
|
+
// One-shot self-recheck: only after a real *user* turn (not meta/auto),
|
|
837
|
+
// with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
|
|
838
|
+
// Also skipped when no files were modified, or when a quiet AI decision
|
|
839
|
+
// refuses (simple tasks, pure build, nothing worth re-verifying).
|
|
840
|
+
const wantSelfRecheck =
|
|
841
|
+
!!final.result &&
|
|
842
|
+
!this.cancelled &&
|
|
843
|
+
!hasQueued &&
|
|
844
|
+
this.cfg.selfRecheckEnabled &&
|
|
845
|
+
!this.skipSelfRecheck &&
|
|
846
|
+
!this.isSelfRecheckTurn;
|
|
847
|
+
|
|
848
|
+
let queuedRecheck = false;
|
|
849
|
+
if (wantSelfRecheck) {
|
|
850
|
+
this.preRecheckAssistantText = this.turnAssistantText;
|
|
851
|
+
// Strip COMPLEXITY wrapper so recheck + suggestions see the real ask.
|
|
852
|
+
this.suggestionUserText = stripDirectiveWrappers(this.turnUserText) || this.turnUserText;
|
|
853
|
+
this.preRecheckFileOps = cloneFileOps(this.fileOps);
|
|
854
|
+
this.setLiveStep("Deciding if self-recheck is needed\u2026");
|
|
855
|
+
this.changed();
|
|
856
|
+
const recheck = await this.maybePlanSelfRecheck();
|
|
857
|
+
this.setLiveStep(undefined);
|
|
858
|
+
// User may cancel during the quiet decision call — first turn still
|
|
859
|
+
// succeeded; never queue a recheck after cancel.
|
|
860
|
+
if (this.cancelled) {
|
|
861
|
+
this.preRecheckFileOps = new Map();
|
|
862
|
+
this.preRecheckAssistantText = "";
|
|
863
|
+
} else if (recheck) {
|
|
864
|
+
queuedRecheck = true;
|
|
865
|
+
// Front of queue; mark skip so the recheck turn never re-arms itself.
|
|
866
|
+
this.queue.unshift(
|
|
867
|
+
textPrompt(recheck, this.turnReplyTo, undefined, { skipSelfRecheck: true }),
|
|
868
|
+
);
|
|
869
|
+
this.changed();
|
|
870
|
+
if (pingDone) {
|
|
871
|
+
// Interim status + first-turn file list (final Done comes after recheck).
|
|
872
|
+
const firstFiles = summarizeFileOps(this.preRecheckFileOps, this.cwd);
|
|
873
|
+
await this.notify(
|
|
874
|
+
`\u{1F50D} Self-recheck \u2014 bugs, logic gaps, related follow-through (once)\u2026\n\n` +
|
|
875
|
+
`\u{1F4C1} After first turn\n${firstFiles}`,
|
|
876
|
+
{ loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb },
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
} else {
|
|
880
|
+
// No recheck — clear frozen first-turn ops (nothing to split later).
|
|
881
|
+
this.preRecheckFileOps = new Map();
|
|
882
|
+
this.preRecheckAssistantText = "";
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
if (!queuedRecheck) {
|
|
887
|
+
// Post-turn suggestions on successful, non-cancelled Done with idle queue —
|
|
888
|
+
// both foreground and background (so switch-to-session can re-show them).
|
|
889
|
+
let doneMarkup = switchKb;
|
|
890
|
+
// After a recheck pass, rebuild Done with split first-turn / recheck files.
|
|
891
|
+
let doneText = this.isSelfRecheckTurn
|
|
892
|
+
? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
|
|
893
|
+
: liveMsg;
|
|
894
|
+
if (final.result && !this.cancelled && !hasQueued) {
|
|
895
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
896
|
+
// Only auto-queue high-need follow-ups when the user is watching;
|
|
897
|
+
// background sessions store buttons for the Done ping / switch replay.
|
|
898
|
+
autoQueue: this.foreground,
|
|
899
|
+
});
|
|
900
|
+
doneText = sug.text;
|
|
901
|
+
doneMarkup = sug.markup;
|
|
902
|
+
}
|
|
903
|
+
if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
|
|
904
|
+
// Clear frozen first-turn ops after final Done (recheck path done).
|
|
905
|
+
if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
|
|
906
|
+
}
|
|
558
907
|
} else if (final.error) {
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
if (
|
|
908
|
+
// If the self-recheck pass itself failed, still surface Done for the
|
|
909
|
+
// original work (split files + suggestions) so the user is not stuck.
|
|
910
|
+
if (this.isSelfRecheckTurn && !hasQueued) {
|
|
911
|
+
const switchKb = this.switchKeyboard();
|
|
912
|
+
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
913
|
+
let doneText =
|
|
914
|
+
this.completionMessageSplit(undefined, startedAt, streamedOutput) +
|
|
915
|
+
`\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
|
|
916
|
+
if (!this.cancelled) {
|
|
917
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
918
|
+
autoQueue: this.foreground,
|
|
919
|
+
});
|
|
920
|
+
doneText = sug.text;
|
|
921
|
+
if (pingDone) {
|
|
922
|
+
await this.notify(doneText, {
|
|
923
|
+
loud: true,
|
|
924
|
+
replyTo: this.turnReplyTo,
|
|
925
|
+
replyMarkup: sug.markup,
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
} else if (pingDone) {
|
|
929
|
+
await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
930
|
+
}
|
|
931
|
+
this.preRecheckFileOps = new Map();
|
|
932
|
+
} else {
|
|
933
|
+
const transient = isTransientError(final.error);
|
|
934
|
+
const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
935
|
+
if (canPing) await this.notify(liveMsg, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
936
|
+
}
|
|
562
937
|
}
|
|
563
938
|
} catch (err) {
|
|
564
939
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
565
940
|
await this.streamer?.finalize().catch(() => {});
|
|
566
|
-
const
|
|
567
|
-
this.
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
941
|
+
const errMsg = (err as Error).message;
|
|
942
|
+
this.setSessionComment(
|
|
943
|
+
buildLastTurnSummary({
|
|
944
|
+
userText: this.turnUserText,
|
|
945
|
+
assistantText: this.turnAssistantText,
|
|
946
|
+
fileOps: this.fileOps,
|
|
947
|
+
error: errMsg,
|
|
948
|
+
}),
|
|
949
|
+
);
|
|
950
|
+
this.setLiveStep(undefined);
|
|
951
|
+
// If the self-recheck pass itself blew up, still surface Done for the
|
|
952
|
+
// original work (split files + suggestions) so the user is not stuck.
|
|
953
|
+
if (this.isSelfRecheckTurn && this.queue.length === 0) {
|
|
954
|
+
const switchKb = this.switchKeyboard();
|
|
955
|
+
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
956
|
+
let doneText =
|
|
957
|
+
this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
|
|
958
|
+
`\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
|
|
959
|
+
try {
|
|
960
|
+
if (!this.cancelled) {
|
|
961
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
962
|
+
autoQueue: this.foreground,
|
|
963
|
+
});
|
|
964
|
+
doneText = sug.text;
|
|
965
|
+
if (canPing) {
|
|
966
|
+
await this.notify(doneText, {
|
|
967
|
+
loud: true,
|
|
968
|
+
replyTo: this.turnReplyTo,
|
|
969
|
+
replyMarkup: sug.markup,
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
} else if (canPing) {
|
|
973
|
+
await this.notify(doneText, {
|
|
974
|
+
loud: true,
|
|
975
|
+
replyTo: this.turnReplyTo,
|
|
976
|
+
replyMarkup: switchKb,
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
} catch (e2) {
|
|
980
|
+
log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
|
|
981
|
+
if (canPing) {
|
|
982
|
+
await this.notify(doneText, {
|
|
983
|
+
loud: true,
|
|
984
|
+
replyTo: this.turnReplyTo,
|
|
985
|
+
replyMarkup: switchKb,
|
|
986
|
+
}).catch(() => {});
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
this.preRecheckFileOps = new Map();
|
|
990
|
+
} else {
|
|
991
|
+
const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
|
|
992
|
+
this.lastCompletion = msg;
|
|
993
|
+
if (this.foreground || this.cfg.notifyOtherSessions) {
|
|
994
|
+
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
995
|
+
await this.notify(`${from}${msg}`, {
|
|
996
|
+
loud: true,
|
|
997
|
+
replyTo: this.turnReplyTo,
|
|
998
|
+
replyMarkup: this.switchKeyboard(),
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
571
1001
|
}
|
|
572
1002
|
} finally {
|
|
573
1003
|
this.typing.stop();
|
|
574
1004
|
this.streamer = undefined;
|
|
1005
|
+
this.capturingQuiet = false;
|
|
1006
|
+
this.quietCaptureBuf = "";
|
|
575
1007
|
this.busy = false;
|
|
576
1008
|
this.activity(false);
|
|
577
1009
|
// The in-flight turn we may have been following live is over.
|
|
@@ -580,6 +1012,9 @@ export class SessionRuntime {
|
|
|
580
1012
|
// the bar is removed from the status panel, session cards and switch
|
|
581
1013
|
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
582
1014
|
this.progress = undefined;
|
|
1015
|
+
this.planEntries = undefined;
|
|
1016
|
+
// Prefer stored summary on cards once idle (clear live step if still set).
|
|
1017
|
+
if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
|
|
583
1018
|
this.changed();
|
|
584
1019
|
}
|
|
585
1020
|
|
|
@@ -594,6 +1029,183 @@ export class SessionRuntime {
|
|
|
594
1029
|
}
|
|
595
1030
|
}
|
|
596
1031
|
|
|
1032
|
+
/**
|
|
1033
|
+
* Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
|
|
1034
|
+
* for switch-replay, and optionally queue auto-approved items as **one**
|
|
1035
|
+
* numbered multi-step prompt (`1) …\n2) …`).
|
|
1036
|
+
*/
|
|
1037
|
+
private async collectAndApplySuggestions(
|
|
1038
|
+
doneText: string,
|
|
1039
|
+
switchKb: InlineKeyboard | undefined,
|
|
1040
|
+
opts?: { autoQueue?: boolean },
|
|
1041
|
+
): Promise<{ text: string; markup?: InlineKeyboard }> {
|
|
1042
|
+
if (!this.cfg.suggestionsEnabled || !this.sessionId) {
|
|
1043
|
+
return { text: doneText, markup: switchKb };
|
|
1044
|
+
}
|
|
1045
|
+
let suggestions: Suggestion[] = [];
|
|
1046
|
+
try {
|
|
1047
|
+
suggestions = await this.fetchSuggestionsQuiet();
|
|
1048
|
+
} catch (e) {
|
|
1049
|
+
log.debug(`suggestions fetch failed: ${(e as Error).message}`);
|
|
1050
|
+
}
|
|
1051
|
+
if (suggestions.length === 0) return { text: doneText, markup: switchKb };
|
|
1052
|
+
|
|
1053
|
+
const batchId = ++this.suggestionBatchSeq;
|
|
1054
|
+
this.suggestionBatches.set(batchId, suggestions);
|
|
1055
|
+
// Bound memory: keep last ~20 batches.
|
|
1056
|
+
if (this.suggestionBatches.size > 20) {
|
|
1057
|
+
const oldest = [...this.suggestionBatches.keys()].sort((a, b) => a - b)[0]!;
|
|
1058
|
+
this.suggestionBatches.delete(oldest);
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const thr = this.cfg.suggestionsAutoApprovePct;
|
|
1062
|
+
const autoQueue = opts?.autoQueue !== false;
|
|
1063
|
+
const auto = autoQueue ? autoApproveSuggestions(suggestions, thr) : [];
|
|
1064
|
+
let text = doneText;
|
|
1065
|
+
let banner: string;
|
|
1066
|
+
if (auto.length > 0) {
|
|
1067
|
+
const batched = formatBatchedSuggestionsPrompt(auto);
|
|
1068
|
+
const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
|
|
1069
|
+
const autoBlock =
|
|
1070
|
+
`\n\n\u{1F4A1} Auto-running ${auto.length} suggestion${auto.length === 1 ? "" : "s"}` +
|
|
1071
|
+
` as one prompt (\u2265 ${thr}% need):\n${lines.join("\n")}`;
|
|
1072
|
+
text += autoBlock;
|
|
1073
|
+
banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
|
|
1074
|
+
// Single queue entry — agent executes 1) 2) 3) in one turn.
|
|
1075
|
+
// skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
|
|
1076
|
+
this.queue.push(textPrompt(batched, this.turnReplyTo, undefined, { skipSelfRecheck: true }));
|
|
1077
|
+
this.changed();
|
|
1078
|
+
} else {
|
|
1079
|
+
text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1080
|
+
banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Keep for switch-to-session replay (and for Done pings that already include
|
|
1084
|
+
// the same keyboard). Cleared when a new turn starts.
|
|
1085
|
+
this.pendingSuggestions = { batchId, suggestions, banner };
|
|
1086
|
+
|
|
1087
|
+
const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
|
|
1088
|
+
return { text, markup };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Decide whether to queue a self-recheck turn.
|
|
1093
|
+
* - Hard skip when no files were modified this turn.
|
|
1094
|
+
* - Quiet AI decision: refuse (simple / not needed) or write recheck prompt.
|
|
1095
|
+
* - Returns the full recheck turn text, or undefined to skip.
|
|
1096
|
+
*/
|
|
1097
|
+
private async maybePlanSelfRecheck(): Promise<string | undefined> {
|
|
1098
|
+
if (this.fileOps.size === 0) {
|
|
1099
|
+
log.info(`chat ${this.chatId}: self-recheck skipped (no files modified)`);
|
|
1100
|
+
return undefined;
|
|
1101
|
+
}
|
|
1102
|
+
if (this.cancelled) return undefined;
|
|
1103
|
+
const user =
|
|
1104
|
+
this.suggestionUserText ||
|
|
1105
|
+
stripDirectiveWrappers(this.turnUserText) ||
|
|
1106
|
+
this.turnUserText;
|
|
1107
|
+
const did = this.turnAssistantText;
|
|
1108
|
+
const files = summarizeFileOpsShort(this.fileOps);
|
|
1109
|
+
|
|
1110
|
+
let decision;
|
|
1111
|
+
try {
|
|
1112
|
+
decision = await this.fetchSelfRecheckDecisionQuiet(user, did, files);
|
|
1113
|
+
} catch (e) {
|
|
1114
|
+
log.debug(`self-recheck decision failed: ${(e as Error).message}; skipping`);
|
|
1115
|
+
return undefined;
|
|
1116
|
+
}
|
|
1117
|
+
if (this.cancelled) return undefined;
|
|
1118
|
+
if (!decision.needed) {
|
|
1119
|
+
log.info(
|
|
1120
|
+
`chat ${this.chatId}: self-recheck skipped by agent` +
|
|
1121
|
+
(decision.reason ? ` (${decision.reason})` : ""),
|
|
1122
|
+
);
|
|
1123
|
+
return undefined;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Optional env template overrides the AI-written body when set.
|
|
1127
|
+
if (this.cfg.selfRecheckPrompt) {
|
|
1128
|
+
return buildSelfRecheckPrompt(user, did, this.cfg.selfRecheckPrompt);
|
|
1129
|
+
}
|
|
1130
|
+
if (decision.prompt.trim()) {
|
|
1131
|
+
return composeSelfRecheckTurn(decision.prompt, user, did);
|
|
1132
|
+
}
|
|
1133
|
+
// needed=true but empty prompt — fall back to built-in default template.
|
|
1134
|
+
return buildSelfRecheckPrompt(user, did);
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
/** Quiet JSON: should we recheck, and if so what prompt? Never streams. */
|
|
1138
|
+
private async fetchSelfRecheckDecisionQuiet(
|
|
1139
|
+
user: string,
|
|
1140
|
+
did: string,
|
|
1141
|
+
filesSummary: string,
|
|
1142
|
+
): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
|
|
1143
|
+
if (!this.sessionId) return { needed: false, reason: "no session" };
|
|
1144
|
+
const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
|
|
1145
|
+
this.capturingQuiet = true;
|
|
1146
|
+
this.quietCaptureBuf = "";
|
|
1147
|
+
try {
|
|
1148
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
|
|
1149
|
+
return parseSelfRecheckDecision(this.quietCaptureBuf);
|
|
1150
|
+
} finally {
|
|
1151
|
+
this.capturingQuiet = false;
|
|
1152
|
+
this.quietCaptureBuf = "";
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Quiet JSON suggestion turn — never streams to Telegram. */
|
|
1157
|
+
private async fetchSuggestionsQuiet(): Promise<Suggestion[]> {
|
|
1158
|
+
if (!this.sessionId) return [];
|
|
1159
|
+
// Prefer the original user ask (before self-recheck) so need scores stay honest.
|
|
1160
|
+
const user =
|
|
1161
|
+
this.suggestionUserText ||
|
|
1162
|
+
stripDirectiveWrappers(this.turnUserText) ||
|
|
1163
|
+
this.turnUserText;
|
|
1164
|
+
const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
|
|
1165
|
+
const did = didParts.join("\n") || this.turnAssistantText;
|
|
1166
|
+
const prompt = buildSuggestionsPrompt(user, did);
|
|
1167
|
+
this.capturingQuiet = true;
|
|
1168
|
+
this.quietCaptureBuf = "";
|
|
1169
|
+
try {
|
|
1170
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
|
|
1171
|
+
return parseSuggestions(this.quietCaptureBuf);
|
|
1172
|
+
} finally {
|
|
1173
|
+
this.capturingQuiet = false;
|
|
1174
|
+
this.quietCaptureBuf = "";
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Resolve a tapped suggestion button; returns the prompt text or undefined. */
|
|
1179
|
+
takeSuggestion(batchId: number, index: number): string | undefined {
|
|
1180
|
+
const batch = this.suggestionBatches.get(batchId);
|
|
1181
|
+
if (!batch) return undefined;
|
|
1182
|
+
const s = batch[index];
|
|
1183
|
+
if (!s) return undefined;
|
|
1184
|
+
return s.text;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Attribute a finished turn's credits/context to the active saved account. */
|
|
1188
|
+
private recordAccountUsage(): void {
|
|
1189
|
+
const meta = this.contextInfo();
|
|
1190
|
+
// Grok's metadata credits are typically a running session total — store the
|
|
1191
|
+
// per-turn delta so /accounts totals stay accurate across many turns.
|
|
1192
|
+
let turnCredits: number | undefined;
|
|
1193
|
+
if (typeof meta?.credits === "number" && Number.isFinite(meta.credits)) {
|
|
1194
|
+
const delta = meta.credits - this.lastReportedCredits;
|
|
1195
|
+
turnCredits = delta > 0 ? delta : meta.credits > 0 && this.lastReportedCredits === 0 ? meta.credits : undefined;
|
|
1196
|
+
if (meta.credits >= this.lastReportedCredits) this.lastReportedCredits = meta.credits;
|
|
1197
|
+
else this.lastReportedCredits = meta.credits; // reset if agent restarted counters
|
|
1198
|
+
}
|
|
1199
|
+
try {
|
|
1200
|
+
this.accountRotator?.recordTurnUsage({
|
|
1201
|
+
credits: turnCredits,
|
|
1202
|
+
contextPct: meta?.contextUsagePercentage,
|
|
1203
|
+
});
|
|
1204
|
+
} catch {
|
|
1205
|
+
/* non-fatal */
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
597
1209
|
/**
|
|
598
1210
|
* Show subagent ("crew") status transitions for the given (already
|
|
599
1211
|
* chat-attributed) subagents, so the user sees progress while the main agent
|
|
@@ -629,6 +1241,51 @@ export class SessionRuntime {
|
|
|
629
1241
|
return pct !== undefined && pct >= threshold;
|
|
630
1242
|
}
|
|
631
1243
|
|
|
1244
|
+
/** A shared-process restart invalidates this runtime's ACP session binding,
|
|
1245
|
+
* but says nothing about account health. Wait for any account probe to
|
|
1246
|
+
* settle, re-bind/fork this chat on the selected account, and retry once. */
|
|
1247
|
+
private async maybeRecoverAgentSession(
|
|
1248
|
+
input: PromptInput,
|
|
1249
|
+
outcome: { result?: PromptResult; error?: Error; attempts: number },
|
|
1250
|
+
): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
|
|
1251
|
+
if (
|
|
1252
|
+
!outcome.error ||
|
|
1253
|
+
!isSessionLifecycleError(outcome.error) ||
|
|
1254
|
+
this.cancelled ||
|
|
1255
|
+
(this.streamer?.hasOutput ?? false)
|
|
1256
|
+
) {
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
try {
|
|
1260
|
+
await this.accountRotator?.waitForIdle();
|
|
1261
|
+
if (this.cancelled) return outcome;
|
|
1262
|
+
const previousId = this.sessionId;
|
|
1263
|
+
this.sessionLive = false;
|
|
1264
|
+
this.rebindPending = Boolean(previousId);
|
|
1265
|
+
await this.ensureSession();
|
|
1266
|
+
this.shownToolIds = new Set();
|
|
1267
|
+
this.subagentShown = new Map();
|
|
1268
|
+
this.streamer?.setFooter(this.hashtags());
|
|
1269
|
+
const retryContent = buildContentBlocks(input, {
|
|
1270
|
+
reasoning: reasoningDirective(this.reasoning),
|
|
1271
|
+
priming: this.primingContext,
|
|
1272
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1273
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1274
|
+
});
|
|
1275
|
+
this.primingContext = undefined;
|
|
1276
|
+
log.info(
|
|
1277
|
+
`chat ${this.chatId} recovered lifecycle error on the active account` +
|
|
1278
|
+
(previousId && this.sessionId !== previousId
|
|
1279
|
+
? ` with fresh session ${this.sessionId?.slice(0, 8)}`
|
|
1280
|
+
: " by re-binding its session"),
|
|
1281
|
+
);
|
|
1282
|
+
return this.runPromptWithRetries(retryContent);
|
|
1283
|
+
} catch (error) {
|
|
1284
|
+
log.warn(`chat ${this.chatId} session recovery failed: ${(error as Error).message}`);
|
|
1285
|
+
return { error: error as Error, attempts: outcome.attempts };
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
632
1289
|
/**
|
|
633
1290
|
* Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
|
|
634
1291
|
* a context-exhaustion error) and nothing was streamed to the user, the
|
|
@@ -700,25 +1357,57 @@ export class SessionRuntime {
|
|
|
700
1357
|
): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
|
|
701
1358
|
const rotator = this.accountRotator;
|
|
702
1359
|
if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
|
|
1360
|
+
if (isSessionLifecycleError(final.error)) return undefined;
|
|
1361
|
+
const originalError = final.error;
|
|
1362
|
+
const observed = rotator.state();
|
|
1363
|
+
return rotator.withRotationLock(observed, async (changed) => {
|
|
1364
|
+
if (this.cancelled) return final;
|
|
1365
|
+
if (changed) {
|
|
1366
|
+
if (this.streamer?.hasOutput ?? false) return undefined;
|
|
1367
|
+
const current = rotator.state();
|
|
1368
|
+
const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
|
|
1369
|
+
if (this.foreground) {
|
|
1370
|
+
await this.notify(
|
|
1371
|
+
`\u{1F504} Reusing ${current.activeLabel ?? "the account selected by another chat"} with a fresh session…`,
|
|
1372
|
+
{ replyTo: this.turnReplyTo },
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
log.info(`chat ${this.chatId} reusing account generation ${current.generation} selected by another chat`);
|
|
1376
|
+
try {
|
|
1377
|
+
await this.bindNewSession(this.cwd, this.projectName);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
return { error: error as Error, attempts: final.attempts };
|
|
1380
|
+
}
|
|
1381
|
+
this.shownToolIds = new Set();
|
|
1382
|
+
this.subagentShown = new Map();
|
|
1383
|
+
this.streamer?.setFooter(this.hashtags());
|
|
1384
|
+
const content = buildContentBlocks(input, {
|
|
1385
|
+
reasoning: reasoningDirective(this.reasoning),
|
|
1386
|
+
priming: transcript ? buildPriming(transcript) : undefined,
|
|
1387
|
+
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1388
|
+
progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1389
|
+
});
|
|
1390
|
+
return this.runPromptWithRetries(content);
|
|
1391
|
+
}
|
|
703
1392
|
// A quota-exhausted or access-denied response cannot be recovered by retrying
|
|
704
1393
|
// this login. Quarantine it before choosing targets, so later rotations do not
|
|
705
1394
|
// cycle back to a known-bad account. This intentionally happens before the
|
|
706
1395
|
// partial-stream guard: we must not retry/rotate a partial reply, but its
|
|
707
1396
|
// account still needs to be skipped during a future rotation.
|
|
708
|
-
if (isAccountRotationError(
|
|
709
|
-
await rotator.markFailed(
|
|
1397
|
+
if (isAccountRotationError(originalError)) {
|
|
1398
|
+
await rotator.markFailed(observed.activeId, originalError.message);
|
|
710
1399
|
}
|
|
711
1400
|
if (this.streamer?.hasOutput ?? false) return undefined;
|
|
712
1401
|
const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
|
|
713
1402
|
if (targets.length === 0) return undefined;
|
|
714
1403
|
|
|
715
1404
|
const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
|
|
716
|
-
const errors: string[] = [`\u2022 previous: ${
|
|
1405
|
+
const errors: string[] = [`\u2022 previous: ${originalError.message}`];
|
|
717
1406
|
let last = final;
|
|
718
1407
|
|
|
719
1408
|
for (const t of targets) {
|
|
720
1409
|
if (this.cancelled) return last;
|
|
721
|
-
const failReason = last.error ??
|
|
1410
|
+
const failReason = last.error ?? originalError;
|
|
722
1411
|
if (this.foreground) {
|
|
723
1412
|
await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
|
|
724
1413
|
}
|
|
@@ -767,6 +1456,7 @@ export class SessionRuntime {
|
|
|
767
1456
|
// One full cycle done and still failing — stop with a combined report.
|
|
768
1457
|
const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
|
|
769
1458
|
return { error: combined, attempts: last.attempts };
|
|
1459
|
+
});
|
|
770
1460
|
}
|
|
771
1461
|
|
|
772
1462
|
/**
|
|
@@ -937,6 +1627,28 @@ export class SessionRuntime {
|
|
|
937
1627
|
return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
|
|
938
1628
|
}
|
|
939
1629
|
|
|
1630
|
+
/**
|
|
1631
|
+
* Final Done after a self-recheck: head + split file lists (first turn vs recheck).
|
|
1632
|
+
*/
|
|
1633
|
+
private completionMessageSplit(
|
|
1634
|
+
stopReason: string | undefined,
|
|
1635
|
+
startedAt: number,
|
|
1636
|
+
streamedOutput: boolean,
|
|
1637
|
+
): string {
|
|
1638
|
+
const head = this.doneHead(stopReason, startedAt, streamedOutput);
|
|
1639
|
+
const tags = this.hashtags();
|
|
1640
|
+
const files = summarizeFileOpsSplit(this.preRecheckFileOps, this.fileOps, this.cwd);
|
|
1641
|
+
const base = `${head}\n${files}`;
|
|
1642
|
+
this.lastCompletion = `${base}\n\n${tags}`;
|
|
1643
|
+
if (this.foreground) {
|
|
1644
|
+
return streamedOutput ? base : `${base}\n\n${tags}`;
|
|
1645
|
+
}
|
|
1646
|
+
return (
|
|
1647
|
+
`\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n` +
|
|
1648
|
+
`${summarizeFileOpsShort(this.preRecheckFileOps)} \u2192 recheck ${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`
|
|
1649
|
+
);
|
|
1650
|
+
}
|
|
1651
|
+
|
|
940
1652
|
/** The compact one-line status of a finished turn (no "end_turn" noise). */
|
|
941
1653
|
private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
|
|
942
1654
|
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
@@ -992,7 +1704,16 @@ export class SessionRuntime {
|
|
|
992
1704
|
|
|
993
1705
|
private async flushQueue(): Promise<void> {
|
|
994
1706
|
if (this.queue.length === 0 || this.busy) return;
|
|
995
|
-
|
|
1707
|
+
// Meta / system turns (self-recheck, auto-suggestion batches) must run
|
|
1708
|
+
// alone: merging them with user messages corrupts the prompt and can drop
|
|
1709
|
+
// the one-shot skipSelfRecheck guard via text concatenation.
|
|
1710
|
+
const head = this.queue[0]!;
|
|
1711
|
+
const isMeta =
|
|
1712
|
+
!!head.skipSelfRecheck ||
|
|
1713
|
+
isSelfRecheckPrompt(head.text);
|
|
1714
|
+
const batch = isMeta
|
|
1715
|
+
? this.queue.shift()!
|
|
1716
|
+
: mergeInputs(this.queue.splice(0, this.queue.length));
|
|
996
1717
|
if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
|
|
997
1718
|
void this.runTurn(batch);
|
|
998
1719
|
}
|
|
@@ -1002,36 +1723,73 @@ export class SessionRuntime {
|
|
|
1002
1723
|
this.sessionUpdateCount++;
|
|
1003
1724
|
const kind = update.sessionUpdate;
|
|
1004
1725
|
|
|
1726
|
+
// Quiet meta turns (follow-up suggestions): capture prose only, never stream.
|
|
1727
|
+
if (this.capturingQuiet) {
|
|
1728
|
+
if (kind === "agent_message_chunk") {
|
|
1729
|
+
const text = contentText(update.content);
|
|
1730
|
+
if (text) this.quietCaptureBuf += text;
|
|
1731
|
+
}
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1005
1735
|
// Accumulate the turn's file-change summary + image-scan text even when this
|
|
1006
1736
|
// session is in the background (its output isn't streamed here, but the
|
|
1007
1737
|
// completion message still reports what changed / which images were made).
|
|
1008
1738
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1739
|
+
// Merge early so background live-step + file ops use full title/args.
|
|
1740
|
+
const tid = update.toolCallId || "";
|
|
1741
|
+
const mergedEarly = mergeToolSnapshot(tid ? this.toolCallCache.get(tid) : undefined, update);
|
|
1742
|
+
if (tid) this.toolCallCache.set(tid, mergedEarly);
|
|
1743
|
+
|
|
1744
|
+
if (mergedEarly.rawInput) this.imageScanText += " " + JSON.stringify(mergedEarly.rawInput);
|
|
1745
|
+
if (mergedEarly.title) this.imageScanText += " " + mergedEarly.title;
|
|
1746
|
+
if (Array.isArray(mergedEarly.content_blocks)) {
|
|
1747
|
+
this.imageScanText += " " + JSON.stringify(mergedEarly.content_blocks);
|
|
1014
1748
|
}
|
|
1015
|
-
|
|
1016
|
-
if (
|
|
1017
|
-
const fo = fileOpFromUpdate(
|
|
1749
|
+
const ct = contentText(mergedEarly.content);
|
|
1750
|
+
if (ct) this.imageScanText += " " + ct;
|
|
1751
|
+
const fo = fileOpFromUpdate(mergedEarly);
|
|
1018
1752
|
if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
|
|
1753
|
+
// Live card step — always, even for background sessions.
|
|
1754
|
+
const step = stepFromToolUpdate(mergedEarly);
|
|
1755
|
+
if (step) this.setLiveStep(step);
|
|
1019
1756
|
} else if (kind === "agent_message_chunk") {
|
|
1020
|
-
const text = update.content
|
|
1021
|
-
if (
|
|
1757
|
+
const text = contentText(update.content);
|
|
1758
|
+
if (text) {
|
|
1759
|
+
this.imageScanText += text;
|
|
1760
|
+
this.turnAssistantText += text;
|
|
1761
|
+
}
|
|
1762
|
+
} else if (kind === "agent_thought_chunk") {
|
|
1763
|
+
const text = contentText(update.content);
|
|
1764
|
+
if (text?.trim()) this.setLiveStep(stepFromThought(text));
|
|
1765
|
+
} else if (kind === "plan") {
|
|
1766
|
+
// Always track plan entries (background too) so switch-to-live restores the board.
|
|
1767
|
+
const entries = parsePlanUpdate(update);
|
|
1768
|
+
if (entries?.length) {
|
|
1769
|
+
this.planEntries = entries;
|
|
1770
|
+
const one = renderPlanOneLine(entries);
|
|
1771
|
+
if (one) this.setLiveStep(one);
|
|
1772
|
+
this.changed();
|
|
1773
|
+
}
|
|
1022
1774
|
}
|
|
1023
1775
|
|
|
1024
1776
|
// Only the live foreground turn streams to Telegram.
|
|
1025
1777
|
if (!this.foreground || !this.streamer) return;
|
|
1026
1778
|
|
|
1779
|
+
if (kind === "plan") {
|
|
1780
|
+
if (this.planEntries?.length) {
|
|
1781
|
+
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
1782
|
+
}
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1027
1785
|
if (kind === "agent_message_chunk") {
|
|
1028
|
-
const text = update.content
|
|
1029
|
-
if (
|
|
1786
|
+
const text = contentText(update.content);
|
|
1787
|
+
if (text) this.streamer.appendOutput(text);
|
|
1030
1788
|
return;
|
|
1031
1789
|
}
|
|
1032
1790
|
if (kind === "agent_thought_chunk") {
|
|
1033
|
-
const text = update.content
|
|
1034
|
-
if (
|
|
1791
|
+
const text = contentText(update.content);
|
|
1792
|
+
if (text) this.streamer.appendThought(text);
|
|
1035
1793
|
return;
|
|
1036
1794
|
}
|
|
1037
1795
|
if (kind === "tool_call" || kind === "tool_call_update") {
|
|
@@ -1039,31 +1797,40 @@ export class SessionRuntime {
|
|
|
1039
1797
|
const id = update.toolCallId || "";
|
|
1040
1798
|
const status = (update.status || "").toLowerCase();
|
|
1041
1799
|
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
this.shownToolIds.
|
|
1800
|
+
// Snapshot already merged above for file-ops / live step.
|
|
1801
|
+
const merged = (id && this.toolCallCache.get(id)) || mergeToolSnapshot(undefined, update);
|
|
1802
|
+
|
|
1803
|
+
// Skip hollow shells with nothing useful yet.
|
|
1804
|
+
if (!snapshotHasDetail(merged) && status !== "completed" && status !== "failed") {
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// Status-only mid-flight patches (no new content/input): skip if we already
|
|
1809
|
+
// painted this tool once — upsert would be a no-op anyway.
|
|
1810
|
+
if (kind === "tool_call_update" && (status === "pending" || status === "in_progress")) {
|
|
1811
|
+
const hasNewContent =
|
|
1812
|
+
(Array.isArray(update.content_blocks) && update.content_blocks.length > 0) ||
|
|
1813
|
+
(Array.isArray(update.content) && (update.content as unknown[]).length > 0) ||
|
|
1814
|
+
(!!update.rawInput && Object.keys(update.rawInput).length > 0) ||
|
|
1815
|
+
update.rawOutput !== undefined;
|
|
1816
|
+
const key = id || `tool_call:${update.title ?? ""}`;
|
|
1817
|
+
if (!hasNewContent && this.shownToolIds.has(key)) return;
|
|
1060
1818
|
}
|
|
1061
1819
|
|
|
1062
|
-
const md = formatToolCall(
|
|
1820
|
+
const md = formatToolCall(merged, {
|
|
1063
1821
|
showDiffs: this.cfg.showEditDiffs,
|
|
1064
1822
|
diffMaxLines: this.cfg.diffMaxLines,
|
|
1065
1823
|
});
|
|
1066
|
-
if (md)
|
|
1824
|
+
if (!md) return;
|
|
1825
|
+
|
|
1826
|
+
// One live card per toolCallId: replace in place as output streams
|
|
1827
|
+
// (no spam of new code sections). Session/agent context keeps full output.
|
|
1828
|
+
const key = id || `tool_call:${merged.title ?? merged.name ?? ""}`;
|
|
1829
|
+
this.shownToolIds.add(key);
|
|
1830
|
+
if (status === "completed" || status === "failed") {
|
|
1831
|
+
this.shownToolIds.add(key + ":done");
|
|
1832
|
+
}
|
|
1833
|
+
this.streamer.upsertTool(id || undefined, md);
|
|
1067
1834
|
}
|
|
1068
1835
|
}
|
|
1069
1836
|
|
|
@@ -1103,8 +1870,8 @@ export class SessionRuntime {
|
|
|
1103
1870
|
}
|
|
1104
1871
|
if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
1105
1872
|
await this.api.sendMessage(this.chatId, text, extra);
|
|
1106
|
-
} catch {
|
|
1107
|
-
|
|
1873
|
+
} catch (e) {
|
|
1874
|
+
log.debug("notify failed:", (e as Error).message);
|
|
1108
1875
|
}
|
|
1109
1876
|
}
|
|
1110
1877
|
|