grok-telegram-bot 2.4.0 → 2.5.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 +38 -2
- package/CHANGELOG.md +119 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +12 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +122 -15
- package/src/bot/chat-controller.ts +52 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +101 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +3 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-runtime.ts +647 -158
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/session-comment.ts +64 -7
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +50 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +17 -6
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -62,6 +62,17 @@ import {
|
|
|
62
62
|
type Suggestion,
|
|
63
63
|
suggestionsKeyboard,
|
|
64
64
|
} from "./suggestions.js";
|
|
65
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
66
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
67
|
+
import type { TelegramBotService } from "./telegram-bots.js";
|
|
68
|
+
import { executeTelegramActions } from "./telegram-actions.js";
|
|
69
|
+
import {
|
|
70
|
+
buildTelegramBridgeDirective,
|
|
71
|
+
buildTelegramBridgeResultsPrompt,
|
|
72
|
+
extractTelegramActions,
|
|
73
|
+
isTelegramBridgeResultsPrompt,
|
|
74
|
+
wrapTelegramBridgePrompt,
|
|
75
|
+
} from "../render/telegram-bridge.js";
|
|
65
76
|
import {
|
|
66
77
|
parsePlanUpdate,
|
|
67
78
|
renderPlanMarkdown,
|
|
@@ -69,9 +80,11 @@ import {
|
|
|
69
80
|
type PlanEntry,
|
|
70
81
|
} from "../render/plan.js";
|
|
71
82
|
import {
|
|
72
|
-
|
|
83
|
+
buildSessionCardComment,
|
|
84
|
+
clampThinking,
|
|
73
85
|
cleanCommentLine,
|
|
74
86
|
cleanUserPreview,
|
|
87
|
+
COMMENT_MAX,
|
|
75
88
|
stepFromThought,
|
|
76
89
|
stepFromToolUpdate,
|
|
77
90
|
stripDirectiveWrappers,
|
|
@@ -144,6 +157,8 @@ export class SessionRuntime {
|
|
|
144
157
|
private turnCount = 0;
|
|
145
158
|
/** Telegram message id of the current turn's prompt, so replies thread to it. */
|
|
146
159
|
private turnReplyTo: number | undefined;
|
|
160
|
+
/** Short id for `#prompt_<id>` on all AI messages of this turn. */
|
|
161
|
+
private turnPromptId: string | undefined;
|
|
147
162
|
private imageScanText = "";
|
|
148
163
|
private sentImagesThisTurn = new Set<string>();
|
|
149
164
|
/** Monotonic count used to reject ACP "success" responses with no turn updates. */
|
|
@@ -172,19 +187,52 @@ export class SessionRuntime {
|
|
|
172
187
|
accountRotator: AccountRotator | undefined;
|
|
173
188
|
/** Session ids that already received the first-prompt auto-complexity directive. */
|
|
174
189
|
private complexitySteered = new Set<string>();
|
|
190
|
+
/** Session ids that already received the first-prompt Telegram bridge directive. */
|
|
191
|
+
private telegramBridgeSteered = new Set<string>();
|
|
192
|
+
/**
|
|
193
|
+
* How many TELEGRAM BRIDGE RESULTS follow-ups are chained after the current
|
|
194
|
+
* user turn. Caps infinite list_bots/bot_command loops; reset on real user work.
|
|
195
|
+
*/
|
|
196
|
+
private bridgeResultDepth = 0;
|
|
197
|
+
/** Max sequential bridge result turns per user request. */
|
|
198
|
+
private static readonly BRIDGE_CHAIN_MAX = 4;
|
|
199
|
+
/**
|
|
200
|
+
* Optional Telegram bridge services (forum / session store / sibling bots).
|
|
201
|
+
* Injected by the registry after construct.
|
|
202
|
+
*/
|
|
203
|
+
bridge?: {
|
|
204
|
+
store: SessionStore;
|
|
205
|
+
forum?: ForumManager;
|
|
206
|
+
bots: TelegramBotService;
|
|
207
|
+
/** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
|
|
208
|
+
submitTopicPrompt?: import("./telegram-actions.js").SubmitTopicPromptFn;
|
|
209
|
+
};
|
|
175
210
|
/** Last credits total reported for this session (for per-turn delta accounting). */
|
|
176
211
|
private lastReportedCredits = 0;
|
|
177
|
-
/** Live "what is happening now" line while a turn is in flight. */
|
|
212
|
+
/** Live "what is happening now" line while a turn is in flight (tools/plan). */
|
|
178
213
|
private liveStep: string | undefined;
|
|
179
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Card comment on disk / idle: last user prompt (≤ COMMENT_MAX).
|
|
216
|
+
* While busy, {@link cardComment} also appends last agent thinking.
|
|
217
|
+
*/
|
|
180
218
|
private sessionComment: string | undefined;
|
|
219
|
+
/** Cleaned last user prompt for cards (not overwritten by self-recheck meta). */
|
|
220
|
+
private cardUserPrompt: string | undefined;
|
|
221
|
+
/** Accumulated agent_thought_chunk text for the current turn (card display). */
|
|
222
|
+
private cardThinking = "";
|
|
181
223
|
/** User text of the turn currently running (for local card-comment fallback). */
|
|
182
224
|
private turnUserText = "";
|
|
183
|
-
/** Assistant prose streamed this turn — used
|
|
225
|
+
/** Assistant prose streamed this turn — used for suggestions / completion. */
|
|
184
226
|
private turnAssistantText = "";
|
|
185
227
|
/** Quiet meta capture (suggestions) — never stream to Telegram. */
|
|
186
228
|
private capturingQuiet = false;
|
|
187
229
|
private quietCaptureBuf = "";
|
|
230
|
+
/**
|
|
231
|
+
* Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
|
|
232
|
+
* one was successfully sent (finally forces a short Done if expected but missing).
|
|
233
|
+
*/
|
|
234
|
+
private turnExpectDone = false;
|
|
235
|
+
private turnDonePinged = false;
|
|
188
236
|
/** Batches of post-turn suggestions for inline-button callbacks. */
|
|
189
237
|
private suggestionBatches = new Map<number, Suggestion[]>();
|
|
190
238
|
private suggestionBatchSeq = 0;
|
|
@@ -214,20 +262,36 @@ export class SessionRuntime {
|
|
|
214
262
|
*/
|
|
215
263
|
private skipSelfRecheck = false;
|
|
216
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Forum topic thread id (message_thread_id). When set, all outbound messages
|
|
267
|
+
* for this runtime are posted into that topic.
|
|
268
|
+
*/
|
|
269
|
+
readonly messageThreadId: number | undefined;
|
|
270
|
+
/** Settings storage key (`chatId` or `chatId:t{threadId}`). */
|
|
271
|
+
readonly settingsKey: string;
|
|
272
|
+
|
|
217
273
|
constructor(
|
|
218
274
|
private readonly api: Api,
|
|
219
275
|
private readonly chatId: number,
|
|
220
276
|
private readonly acp: GrokClient,
|
|
221
277
|
private readonly cfg: AppConfig,
|
|
222
278
|
private readonly settings: SettingsStore,
|
|
223
|
-
init?: {
|
|
279
|
+
init?: {
|
|
280
|
+
cwd: string;
|
|
281
|
+
projectName?: string;
|
|
282
|
+
sessionId?: string;
|
|
283
|
+
messageThreadId?: number;
|
|
284
|
+
settingsKey?: string;
|
|
285
|
+
},
|
|
224
286
|
) {
|
|
287
|
+
this.messageThreadId = init?.messageThreadId;
|
|
288
|
+
this.settingsKey = init?.settingsKey ?? String(chatId);
|
|
225
289
|
if (init) {
|
|
226
290
|
this.cwd = init.cwd;
|
|
227
291
|
this.projectName = init.projectName;
|
|
228
292
|
this.sessionId = init.sessionId;
|
|
229
293
|
} else {
|
|
230
|
-
const s = settings.
|
|
294
|
+
const s = settings.getKey(this.settingsKey);
|
|
231
295
|
this.cwd = s.projectPath ?? cfg.workspace;
|
|
232
296
|
this.projectName = s.projectName;
|
|
233
297
|
this.sessionId = s.sessionId;
|
|
@@ -299,14 +363,23 @@ export class SessionRuntime {
|
|
|
299
363
|
}
|
|
300
364
|
|
|
301
365
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
366
|
+
* Session card comment:
|
|
367
|
+
* always — last user prompt (≤250)
|
|
368
|
+
* busy — plus last AI agent thinking on the next line (≤250)
|
|
304
369
|
*/
|
|
305
370
|
get cardComment(): string | undefined {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
371
|
+
const user =
|
|
372
|
+
this.cardUserPrompt ||
|
|
373
|
+
this.sessionComment ||
|
|
374
|
+
(this.sessionId ? this.acp.sessionComment(this.sessionId) : undefined) ||
|
|
375
|
+
cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX) ||
|
|
376
|
+
undefined;
|
|
377
|
+
const built = buildSessionCardComment({
|
|
378
|
+
userPrompt: user,
|
|
379
|
+
thinking: this.busy && this.cardThinking ? this.cardThinking : undefined,
|
|
380
|
+
busy: this.busy,
|
|
381
|
+
});
|
|
382
|
+
return built || undefined;
|
|
310
383
|
}
|
|
311
384
|
|
|
312
385
|
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
@@ -319,7 +392,7 @@ export class SessionRuntime {
|
|
|
319
392
|
this.changed();
|
|
320
393
|
}
|
|
321
394
|
|
|
322
|
-
/** Update the live step
|
|
395
|
+
/** Update the live step (tools/plan) — kept for diagnostics; cards use user+thinking. */
|
|
323
396
|
private setLiveStep(step: string | undefined): void {
|
|
324
397
|
const next = step?.trim() ? cleanCommentLine(step) : undefined;
|
|
325
398
|
if (next === this.liveStep) return;
|
|
@@ -327,9 +400,21 @@ export class SessionRuntime {
|
|
|
327
400
|
this.changed();
|
|
328
401
|
}
|
|
329
402
|
|
|
330
|
-
/**
|
|
403
|
+
/** Append thought text and refresh cards when the display line changes. */
|
|
404
|
+
private appendCardThinking(chunk: string): void {
|
|
405
|
+
const piece = chunk.replace(/\s+/g, " ").trim();
|
|
406
|
+
if (!piece) return;
|
|
407
|
+
const prevShown = this.cardThinking ? clampThinking(this.cardThinking, COMMENT_MAX) : "";
|
|
408
|
+
this.cardThinking = this.cardThinking ? `${this.cardThinking} ${piece}` : piece;
|
|
409
|
+
const nextShown = clampThinking(this.cardThinking, COMMENT_MAX);
|
|
410
|
+
if (nextShown !== prevShown) this.changed();
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Persist last user prompt (disk + memory) so /running and /sessions see it. */
|
|
331
414
|
private setSessionComment(comment: string | undefined): void {
|
|
332
|
-
const next = comment?.trim()
|
|
415
|
+
const next = comment?.trim()
|
|
416
|
+
? cleanUserPreview(comment, COMMENT_MAX) || cleanCommentLine(comment, COMMENT_MAX)
|
|
417
|
+
: undefined;
|
|
333
418
|
if (next === this.sessionComment) return;
|
|
334
419
|
this.sessionComment = next;
|
|
335
420
|
if (next && this.sessionId) {
|
|
@@ -342,11 +427,25 @@ export class SessionRuntime {
|
|
|
342
427
|
this.changed();
|
|
343
428
|
}
|
|
344
429
|
|
|
345
|
-
/**
|
|
430
|
+
/** Keep disk/memory comment = last real user prompt after a turn ends. */
|
|
431
|
+
private persistCardUserPrompt(): void {
|
|
432
|
+
const prompt =
|
|
433
|
+
this.cardUserPrompt ||
|
|
434
|
+
cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX);
|
|
435
|
+
if (prompt) {
|
|
436
|
+
this.cardUserPrompt = prompt;
|
|
437
|
+
this.setSessionComment(prompt);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Hydrate last user prompt from disk after bind/resume. */
|
|
346
442
|
private loadPersistedComment(): void {
|
|
347
443
|
if (!this.sessionId) return;
|
|
348
444
|
const c = this.acp.sessionComment(this.sessionId);
|
|
349
|
-
if (c)
|
|
445
|
+
if (c) {
|
|
446
|
+
this.sessionComment = c;
|
|
447
|
+
if (!this.cardUserPrompt) this.cardUserPrompt = cleanUserPreview(c, COMMENT_MAX) || c;
|
|
448
|
+
}
|
|
350
449
|
}
|
|
351
450
|
|
|
352
451
|
/** Searchable hashtag footer for this session (project В· session В· model В·
|
|
@@ -369,7 +468,17 @@ export class SessionRuntime {
|
|
|
369
468
|
if (this.busy && !this.streamer) {
|
|
370
469
|
// Any transient follow-watch of this session is now superseded.
|
|
371
470
|
if (this.watchIsFollow) this.stopWatch();
|
|
372
|
-
this.streamer = new ResponseStreamer(
|
|
471
|
+
this.streamer = new ResponseStreamer(
|
|
472
|
+
this.api,
|
|
473
|
+
this.chatId,
|
|
474
|
+
this.cfg.streamThrottleMs,
|
|
475
|
+
this.turnReplyTo,
|
|
476
|
+
this.hashtags(),
|
|
477
|
+
(pct) => this.setProgress(pct),
|
|
478
|
+
this.cfg.progressFallback,
|
|
479
|
+
this.turnStartedAt,
|
|
480
|
+
this.messageThreadId,
|
|
481
|
+
);
|
|
373
482
|
// Restore the live plan board so steps stay visible above the progress bar.
|
|
374
483
|
if (this.planEntries?.length) {
|
|
375
484
|
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
@@ -390,13 +499,16 @@ export class SessionRuntime {
|
|
|
390
499
|
this.changed();
|
|
391
500
|
}
|
|
392
501
|
get reasoning(): ReasoningEffort {
|
|
393
|
-
return this.settings.
|
|
502
|
+
return this.settings.getKey(this.settingsKey).reasoning;
|
|
394
503
|
}
|
|
395
504
|
get agent(): string | undefined {
|
|
396
|
-
return this.settings.
|
|
505
|
+
return this.settings.getKey(this.settingsKey).agent;
|
|
397
506
|
}
|
|
398
507
|
get model(): string | undefined {
|
|
399
|
-
return this.settings.
|
|
508
|
+
return this.settings.getKey(this.settingsKey).model;
|
|
509
|
+
}
|
|
510
|
+
get preferredAccountId(): string | undefined {
|
|
511
|
+
return this.settings.getKey(this.settingsKey).preferredAccountId;
|
|
400
512
|
}
|
|
401
513
|
|
|
402
514
|
/** Latest context-usage % / effort / credits for the current session. */
|
|
@@ -440,6 +552,8 @@ export class SessionRuntime {
|
|
|
440
552
|
this.lastReportedCredits = 0;
|
|
441
553
|
this.liveStep = undefined;
|
|
442
554
|
this.sessionComment = undefined;
|
|
555
|
+
this.cardUserPrompt = undefined;
|
|
556
|
+
this.cardThinking = "";
|
|
443
557
|
await this.applySessionPrefs();
|
|
444
558
|
this.persist();
|
|
445
559
|
this.sessionChanged();
|
|
@@ -495,8 +609,8 @@ export class SessionRuntime {
|
|
|
495
609
|
async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
|
|
496
610
|
await this.startNewSession(cwd, projectName);
|
|
497
611
|
if (priming.trim()) this.primingContext = priming;
|
|
498
|
-
// Imported transcripts already have context — skip first-prompt
|
|
499
|
-
this.
|
|
612
|
+
// Imported transcripts already have context — skip first-prompt directives.
|
|
613
|
+
this.markFirstPromptSteered();
|
|
500
614
|
}
|
|
501
615
|
|
|
502
616
|
startWatch(jsonlPath: string, follow = false): void {
|
|
@@ -519,7 +633,7 @@ export class SessionRuntime {
|
|
|
519
633
|
async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
|
|
520
634
|
// Persist the choice always; only talk to Grok when a session is live in
|
|
521
635
|
// the current process (set_model on an unloaded session crashes the agent).
|
|
522
|
-
this.settings.
|
|
636
|
+
this.settings.updateKey(this.settingsKey, { model: modelId });
|
|
523
637
|
if (modelId && this.sessionLive && this.sessionId) {
|
|
524
638
|
if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
|
|
525
639
|
try {
|
|
@@ -534,7 +648,7 @@ export class SessionRuntime {
|
|
|
534
648
|
}
|
|
535
649
|
|
|
536
650
|
async setAgentPref(agent: string): Promise<void> {
|
|
537
|
-
this.settings.
|
|
651
|
+
this.settings.updateKey(this.settingsKey, { agent });
|
|
538
652
|
if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
|
|
539
653
|
try {
|
|
540
654
|
await this.acp.setMode(this.sessionId, agent);
|
|
@@ -546,22 +660,29 @@ export class SessionRuntime {
|
|
|
546
660
|
}
|
|
547
661
|
|
|
548
662
|
setReasoningPref(effort: ReasoningEffort): void {
|
|
549
|
-
this.settings.
|
|
663
|
+
this.settings.updateKey(this.settingsKey, { reasoning: effort });
|
|
664
|
+
this.changed();
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
setPreferredAccountId(id: string | undefined): void {
|
|
668
|
+
this.settings.updateKey(this.settingsKey, { preferredAccountId: id || undefined });
|
|
669
|
+
// Force re-apply on next ensureSession when user changes preference.
|
|
670
|
+
this.preferredAccountApplied = undefined;
|
|
550
671
|
this.changed();
|
|
551
672
|
}
|
|
552
673
|
|
|
553
674
|
private async applySessionPrefs(): Promise<void> {
|
|
554
|
-
const s = this.settings.
|
|
675
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
555
676
|
// Drop any persisted model the agent doesn't actually offer (an unknown id
|
|
556
677
|
// is silently accepted by set_model but then breaks the next prompt).
|
|
557
678
|
if (s.model && !this.acp.hasModel(s.model)) {
|
|
558
|
-
log.warn(`clearing invalid persisted model "${s.model}" for
|
|
559
|
-
this.settings.
|
|
679
|
+
log.warn(`clearing invalid persisted model "${s.model}" for scope ${this.settingsKey}`);
|
|
680
|
+
this.settings.updateKey(this.settingsKey, { model: "" });
|
|
560
681
|
}
|
|
561
|
-
const cur = this.settings.
|
|
682
|
+
const cur = this.settings.getKey(this.settingsKey);
|
|
562
683
|
// Adopt the session's current agent (mode) when the user hasn't chosen one.
|
|
563
684
|
if (!cur.agent && this.acp.currentModeId) {
|
|
564
|
-
this.settings.
|
|
685
|
+
this.settings.updateKey(this.settingsKey, { agent: this.acp.currentModeId });
|
|
565
686
|
} else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
|
|
566
687
|
try {
|
|
567
688
|
await this.acp.setMode(this.sessionId, cur.agent);
|
|
@@ -587,35 +708,64 @@ export class SessionRuntime {
|
|
|
587
708
|
this.changed();
|
|
588
709
|
return "queued";
|
|
589
710
|
}
|
|
590
|
-
// First
|
|
591
|
-
//
|
|
592
|
-
|
|
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);
|
|
711
|
+
// First-prompt steering is applied inside runTurn so queued first messages
|
|
712
|
+
// (and flushQueue) get the same complexity + telegram bridge directives.
|
|
713
|
+
void this.runTurn(input);
|
|
599
714
|
return "ran";
|
|
600
715
|
}
|
|
601
716
|
|
|
602
|
-
private
|
|
603
|
-
if (this.sessionId)
|
|
717
|
+
private markFirstPromptSteered(): void {
|
|
718
|
+
if (!this.sessionId) return;
|
|
719
|
+
this.complexitySteered.add(this.sessionId);
|
|
720
|
+
this.telegramBridgeSteered.add(this.sessionId);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
private telegramBridgeDirective(): string {
|
|
724
|
+
return buildTelegramBridgeDirective({
|
|
725
|
+
forumReady: !!this.bridge?.forum?.isReady,
|
|
726
|
+
topicGroupId: this.cfg.topicGroupId,
|
|
727
|
+
allowedBots: this.cfg.allowedTelegramBots,
|
|
728
|
+
botCommands: this.cfg.telegramBotCommands,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Complexity + telegram bridge teaching on the first prompt of a brand-new
|
|
734
|
+
* conversation only (no prior user turns in this process / session jsonl).
|
|
735
|
+
*/
|
|
736
|
+
private applyFirstPromptSteering(input: PromptInput): PromptInput {
|
|
737
|
+
if (!this.shouldSteerFirstPrompt(input)) return input;
|
|
738
|
+
let toRun = wrapAutoComplexityPrompt(input);
|
|
739
|
+
toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
|
|
740
|
+
this.markFirstPromptSteered();
|
|
741
|
+
log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
|
|
742
|
+
return toRun;
|
|
604
743
|
}
|
|
605
744
|
|
|
606
745
|
/**
|
|
607
|
-
* Apply
|
|
608
|
-
*
|
|
746
|
+
* Apply first-prompt directives only on a brand-new conversation (no prior
|
|
747
|
+
* user turns in this process / session jsonl).
|
|
609
748
|
*/
|
|
610
|
-
private
|
|
749
|
+
private shouldSteerFirstPrompt(input: PromptInput): boolean {
|
|
611
750
|
if (!this.sessionId) return false;
|
|
612
|
-
if (this.complexitySteered.has(this.sessionId))
|
|
751
|
+
if (this.complexitySteered.has(this.sessionId) && this.telegramBridgeSteered.has(this.sessionId)) {
|
|
752
|
+
return false;
|
|
753
|
+
}
|
|
613
754
|
if (this.turnCount > 0) return false;
|
|
755
|
+
// Never wrap meta follow-ups even if somehow first.
|
|
756
|
+
if (
|
|
757
|
+
input.skipSelfRecheck ||
|
|
758
|
+
isSelfRecheckPrompt(input.text) ||
|
|
759
|
+
isTelegramBridgeResultsPrompt(input.text)
|
|
760
|
+
) {
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
614
763
|
try {
|
|
615
764
|
const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
|
|
616
765
|
const hist = readHistory(path, 8);
|
|
617
766
|
if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
|
|
618
767
|
this.complexitySteered.add(this.sessionId);
|
|
768
|
+
this.telegramBridgeSteered.add(this.sessionId);
|
|
619
769
|
return false;
|
|
620
770
|
}
|
|
621
771
|
} catch {
|
|
@@ -624,9 +774,16 @@ export class SessionRuntime {
|
|
|
624
774
|
return true;
|
|
625
775
|
}
|
|
626
776
|
|
|
777
|
+
/**
|
|
778
|
+
* Stop the current turn for this runtime only.
|
|
779
|
+
* Soft ACP cancel + session-scoped force-complete; never kills the shared
|
|
780
|
+
* agent (that would stop every multiplexed chat and take the bot offline).
|
|
781
|
+
*/
|
|
627
782
|
async cancel(): Promise<boolean> {
|
|
628
783
|
if (!this.busy || !this.sessionId) return false;
|
|
629
784
|
this.cancelled = true;
|
|
785
|
+
// Clear queue of follow-ups for this turn? No — only stop the active turn;
|
|
786
|
+
// queued user messages remain so the user can flush later if they want.
|
|
630
787
|
await this.acp.cancel(this.sessionId);
|
|
631
788
|
return true;
|
|
632
789
|
}
|
|
@@ -647,6 +804,7 @@ export class SessionRuntime {
|
|
|
647
804
|
// Account rotation restarts the process globally. Do not bind a new chat
|
|
648
805
|
// to a candidate account until the owner has finished probing it.
|
|
649
806
|
await this.accountRotator?.waitForIdle();
|
|
807
|
+
await this.applyPreferredAccount();
|
|
650
808
|
if (this.rebindPending && this.sessionId) {
|
|
651
809
|
// The ACP process is frequently mid-restart the first time we re-bind
|
|
652
810
|
// (auto-restart after a crash, or a fresh bot boot), so a single attempt
|
|
@@ -671,6 +829,38 @@ export class SessionRuntime {
|
|
|
671
829
|
if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
|
|
672
830
|
}
|
|
673
831
|
|
|
832
|
+
/** Last preferred account we successfully aligned to (avoids activate thrash). */
|
|
833
|
+
private preferredAccountApplied?: string;
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* If this scope prefers a saved account and the process is on another login,
|
|
837
|
+
* switch before binding the session. Skips when another turn is in flight
|
|
838
|
+
* (account switch restarts the agent) or we already applied this preference.
|
|
839
|
+
*/
|
|
840
|
+
private async applyPreferredAccount(): Promise<void> {
|
|
841
|
+
const preferred = this.preferredAccountId;
|
|
842
|
+
const rotator = this.accountRotator;
|
|
843
|
+
if (!preferred || !rotator) return;
|
|
844
|
+
const st = rotator.state();
|
|
845
|
+
if (st.activeId === preferred) {
|
|
846
|
+
this.preferredAccountApplied = preferred;
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
// User cleared or changed preference — allow one more activate.
|
|
850
|
+
if (this.preferredAccountApplied === preferred) return;
|
|
851
|
+
if (this.acp.hasInflightPrompt()) {
|
|
852
|
+
log.debug(`scope ${this.settingsKey}: skip preferred account (turn in flight)`);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
await rotator.activate(preferred);
|
|
857
|
+
this.preferredAccountApplied = preferred;
|
|
858
|
+
log.info(`scope ${this.settingsKey}: activated preferred account ${preferred.slice(0, 8)}`);
|
|
859
|
+
} catch (e) {
|
|
860
|
+
log.debug(`preferred account activate failed: ${(e as Error).message}`);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
674
864
|
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
675
865
|
* Returns true once loaded, false after the attempts are exhausted. */
|
|
676
866
|
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
@@ -711,20 +901,46 @@ export class SessionRuntime {
|
|
|
711
901
|
}
|
|
712
902
|
|
|
713
903
|
private async runTurn(input: PromptInput): Promise<void> {
|
|
904
|
+
// Apply before any turn bookkeeping so card previews / logs see the wrapped
|
|
905
|
+
// text the same way the agent does (also covers flushQueue first messages).
|
|
906
|
+
input = this.applyFirstPromptSteering(input);
|
|
907
|
+
|
|
714
908
|
this.busy = true;
|
|
715
909
|
this.cancelled = false;
|
|
716
910
|
this.turnReplyTo = input.replyTo;
|
|
911
|
+
this.turnPromptId = input.promptId;
|
|
717
912
|
this.turnUserText = input.text;
|
|
718
913
|
this.turnAssistantText = "";
|
|
914
|
+
this.cardThinking = "";
|
|
915
|
+
this.turnExpectDone = false;
|
|
916
|
+
this.turnDonePinged = false;
|
|
719
917
|
this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
|
|
720
|
-
// Meta turns (recheck, auto-suggestion batches) never arm another recheck.
|
|
721
|
-
|
|
722
|
-
|
|
918
|
+
// Meta turns (recheck, bridge results, auto-suggestion batches) never arm another recheck.
|
|
919
|
+
const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
|
|
920
|
+
this.skipSelfRecheck =
|
|
921
|
+
!!input.skipSelfRecheck ||
|
|
922
|
+
this.isSelfRecheckTurn ||
|
|
923
|
+
isBridgeResults;
|
|
924
|
+
// Fresh user work resets bridge-chain depth + suggestion anchors.
|
|
925
|
+
if (!this.isSelfRecheckTurn && !isBridgeResults) {
|
|
926
|
+
this.bridgeResultDepth = 0;
|
|
927
|
+
}
|
|
928
|
+
// Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
|
|
723
929
|
// Strip complexity/reply wrappers so suggestions + recheck see the real ask.
|
|
724
|
-
if (!this.isSelfRecheckTurn) {
|
|
930
|
+
if (!this.isSelfRecheckTurn && !isBridgeResults) {
|
|
725
931
|
this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
|
|
726
932
|
this.preRecheckAssistantText = "";
|
|
727
933
|
this.preRecheckFileOps = new Map();
|
|
934
|
+
// Card comment: last real user prompt (not self-recheck / empty meta).
|
|
935
|
+
const preview = input.text.trim()
|
|
936
|
+
? cleanUserPreview(input.text, COMMENT_MAX)
|
|
937
|
+
: input.images.length
|
|
938
|
+
? "Attached image(s)"
|
|
939
|
+
: "";
|
|
940
|
+
if (preview) {
|
|
941
|
+
this.cardUserPrompt = preview;
|
|
942
|
+
this.setSessionComment(preview);
|
|
943
|
+
}
|
|
728
944
|
}
|
|
729
945
|
this.shownToolIds = new Set();
|
|
730
946
|
this.toolCallCache = new Map();
|
|
@@ -749,7 +965,17 @@ export class SessionRuntime {
|
|
|
749
965
|
const startedAt = Date.now();
|
|
750
966
|
this.turnStartedAt = startedAt;
|
|
751
967
|
this.streamer = live
|
|
752
|
-
? new ResponseStreamer(
|
|
968
|
+
? new ResponseStreamer(
|
|
969
|
+
this.api,
|
|
970
|
+
this.chatId,
|
|
971
|
+
this.cfg.streamThrottleMs,
|
|
972
|
+
this.turnReplyTo,
|
|
973
|
+
this.hashtags(),
|
|
974
|
+
(pct) => this.setProgress(pct),
|
|
975
|
+
this.cfg.progressFallback,
|
|
976
|
+
startedAt,
|
|
977
|
+
this.messageThreadId,
|
|
978
|
+
)
|
|
753
979
|
: undefined;
|
|
754
980
|
if (live) this.typing.start();
|
|
755
981
|
this.activity(true);
|
|
@@ -786,6 +1012,15 @@ export class SessionRuntime {
|
|
|
786
1012
|
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
787
1013
|
if (this.streamer) await this.streamer.finalize();
|
|
788
1014
|
if (this.foreground) await this.sendTurnImages();
|
|
1015
|
+
|
|
1016
|
+
// Telegram bridge actions (JSON fences in the agent reply). Process on
|
|
1017
|
+
// normal turns AND bridge-results follow-ups so multi-step bot_command /
|
|
1018
|
+
// search chains work; depth cap prevents infinite loops.
|
|
1019
|
+
let queuedBridgeResults = false;
|
|
1020
|
+
if (final.result && !this.cancelled) {
|
|
1021
|
+
queuedBridgeResults = await this.processTelegramBridgeActions();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
789
1024
|
// Always build the completion (records `lastCompletion` so switching back
|
|
790
1025
|
// to this session can replay its Done + summary). Only PING the chat for
|
|
791
1026
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
@@ -798,49 +1033,40 @@ export class SessionRuntime {
|
|
|
798
1033
|
this.turnCount++;
|
|
799
1034
|
// Persist real per-account usage (turns + reported credits) for /accounts and /usage.
|
|
800
1035
|
this.recordAccountUsage();
|
|
801
|
-
// Card comment:
|
|
802
|
-
this.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
stopReason: final.result.stopReason,
|
|
808
|
-
}),
|
|
809
|
-
);
|
|
810
|
-
this.setLiveStep(undefined);
|
|
1036
|
+
// Card comment: last user prompt (thinking cleared when idle).
|
|
1037
|
+
this.persistCardUserPrompt();
|
|
1038
|
+
this.cardThinking = "";
|
|
1039
|
+
// Keep live step while bridge/sibling-bot results are still queued —
|
|
1040
|
+
// clearing here made "Waiting for bot" vanish before the interim notify.
|
|
1041
|
+
if (!queuedBridgeResults) this.setLiveStep(undefined);
|
|
811
1042
|
} else if (this.cancelled) {
|
|
812
|
-
this.
|
|
813
|
-
|
|
814
|
-
userText: this.turnUserText,
|
|
815
|
-
assistantText: this.turnAssistantText,
|
|
816
|
-
fileOps: this.fileOps,
|
|
817
|
-
cancelled: true,
|
|
818
|
-
}),
|
|
819
|
-
);
|
|
1043
|
+
this.persistCardUserPrompt();
|
|
1044
|
+
this.cardThinking = "";
|
|
820
1045
|
this.setLiveStep(undefined);
|
|
821
1046
|
} else if (final.error) {
|
|
822
|
-
this.
|
|
823
|
-
|
|
824
|
-
userText: this.turnUserText,
|
|
825
|
-
assistantText: this.turnAssistantText,
|
|
826
|
-
fileOps: this.fileOps,
|
|
827
|
-
error: final.error.message,
|
|
828
|
-
}),
|
|
829
|
-
);
|
|
1047
|
+
this.persistCardUserPrompt();
|
|
1048
|
+
this.cardThinking = "";
|
|
830
1049
|
this.setLiveStep(undefined);
|
|
831
1050
|
}
|
|
832
1051
|
if (final.result || this.cancelled) {
|
|
833
|
-
|
|
834
|
-
|
|
1052
|
+
// Bridge results in the queue mean "not Done yet" — never treat as a
|
|
1053
|
+
// completion ping even in the foreground. Do not post bridge status
|
|
1054
|
+
// spam to the chat (live step / status panel only).
|
|
1055
|
+
const pingDone =
|
|
1056
|
+
canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
|
|
1057
|
+
// Expect a Done this turn unless we defer for recheck (bridge already excluded).
|
|
1058
|
+
this.turnExpectDone = pingDone;
|
|
835
1059
|
|
|
836
1060
|
// One-shot self-recheck: only after a real *user* turn (not meta/auto),
|
|
837
1061
|
// with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
|
|
838
1062
|
// Also skipped when no files were modified, or when a quiet AI decision
|
|
839
1063
|
// refuses (simple tasks, pure build, nothing worth re-verifying).
|
|
1064
|
+
// Delay recheck/Done when bridge results are queued (like self-recheck).
|
|
840
1065
|
const wantSelfRecheck =
|
|
841
1066
|
!!final.result &&
|
|
842
1067
|
!this.cancelled &&
|
|
843
1068
|
!hasQueued &&
|
|
1069
|
+
!queuedBridgeResults &&
|
|
844
1070
|
this.cfg.selfRecheckEnabled &&
|
|
845
1071
|
!this.skipSelfRecheck &&
|
|
846
1072
|
!this.isSelfRecheckTurn;
|
|
@@ -853,6 +1079,13 @@ export class SessionRuntime {
|
|
|
853
1079
|
this.preRecheckFileOps = cloneFileOps(this.fileOps);
|
|
854
1080
|
this.setLiveStep("Deciding if self-recheck is needed\u2026");
|
|
855
1081
|
this.changed();
|
|
1082
|
+
// Visible chat status so stream-complete is not mistaken for a silent exit.
|
|
1083
|
+
if (pingDone) {
|
|
1084
|
+
await this.notify(
|
|
1085
|
+
"\u{1F50D} Checking if a quality pass is needed\u2026",
|
|
1086
|
+
{ loud: true, replyTo: this.turnReplyTo },
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
856
1089
|
const recheck = await this.maybePlanSelfRecheck();
|
|
857
1090
|
this.setLiveStep(undefined);
|
|
858
1091
|
// User may cancel during the quiet decision call — first turn still
|
|
@@ -862,9 +1095,13 @@ export class SessionRuntime {
|
|
|
862
1095
|
this.preRecheckAssistantText = "";
|
|
863
1096
|
} else if (recheck) {
|
|
864
1097
|
queuedRecheck = true;
|
|
1098
|
+
this.turnExpectDone = false; // final Done comes after the recheck turn
|
|
865
1099
|
// Front of queue; mark skip so the recheck turn never re-arms itself.
|
|
866
1100
|
this.queue.unshift(
|
|
867
|
-
textPrompt(recheck, this.turnReplyTo, undefined, {
|
|
1101
|
+
textPrompt(recheck, this.turnReplyTo, undefined, {
|
|
1102
|
+
skipSelfRecheck: true,
|
|
1103
|
+
promptId: this.turnPromptId,
|
|
1104
|
+
}),
|
|
868
1105
|
);
|
|
869
1106
|
this.changed();
|
|
870
1107
|
if (pingDone) {
|
|
@@ -883,123 +1120,176 @@ export class SessionRuntime {
|
|
|
883
1120
|
}
|
|
884
1121
|
}
|
|
885
1122
|
|
|
886
|
-
if (!queuedRecheck) {
|
|
887
|
-
//
|
|
888
|
-
//
|
|
889
|
-
let doneMarkup = switchKb;
|
|
890
|
-
// After a recheck pass, rebuild Done with split first-turn / recheck files.
|
|
1123
|
+
if (!queuedRecheck && !queuedBridgeResults) {
|
|
1124
|
+
// Build Done text *now* (after quiet decision) so a cancel during the
|
|
1125
|
+
// recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
|
|
891
1126
|
let doneText = this.isSelfRecheckTurn
|
|
892
1127
|
? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
|
|
893
|
-
:
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1128
|
+
: this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
1129
|
+
// 1) Always send Done FIRST — never block the completion ping on the
|
|
1130
|
+
// quiet suggestions prompt (which can hang and look like "no Done").
|
|
1131
|
+
let doneMsgId: number | undefined;
|
|
1132
|
+
if (pingDone) {
|
|
1133
|
+
doneMsgId = await this.notify(doneText, {
|
|
1134
|
+
loud: true,
|
|
1135
|
+
replyTo: this.turnReplyTo,
|
|
1136
|
+
replyMarkup: switchKb,
|
|
899
1137
|
});
|
|
900
|
-
|
|
901
|
-
|
|
1138
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1139
|
+
}
|
|
1140
|
+
// 2) Suggestions after Done: edit the Done message (or send a follow-up).
|
|
1141
|
+
if (final.result && !this.cancelled && !hasQueued) {
|
|
1142
|
+
try {
|
|
1143
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
1144
|
+
// Only auto-queue high-need follow-ups when the user is watching;
|
|
1145
|
+
// background sessions store buttons for the Done ping / switch replay.
|
|
1146
|
+
autoQueue: this.foreground,
|
|
1147
|
+
});
|
|
1148
|
+
// Only enhance when suggestions actually changed the Done body.
|
|
1149
|
+
if (pingDone && sug.text !== doneText) {
|
|
1150
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1151
|
+
}
|
|
1152
|
+
} catch (e) {
|
|
1153
|
+
log.debug(`suggestions after Done failed: ${(e as Error).message}`);
|
|
1154
|
+
}
|
|
902
1155
|
}
|
|
903
|
-
if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
|
|
904
1156
|
// Clear frozen first-turn ops after final Done (recheck path done).
|
|
905
1157
|
if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
|
|
906
1158
|
}
|
|
1159
|
+
// queuedBridgeResults: stay quiet in chat — agent gets results via queue.
|
|
907
1160
|
} else if (final.error) {
|
|
908
1161
|
// If the self-recheck pass itself failed, still surface Done for the
|
|
909
1162
|
// original work (split files + suggestions) so the user is not stuck.
|
|
910
1163
|
if (this.isSelfRecheckTurn && !hasQueued) {
|
|
911
1164
|
const switchKb = this.switchKeyboard();
|
|
912
1165
|
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
1166
|
+
this.turnExpectDone = pingDone;
|
|
913
1167
|
let doneText =
|
|
914
1168
|
this.completionMessageSplit(undefined, startedAt, streamedOutput) +
|
|
915
1169
|
`\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1170
|
+
let doneMsgId: number | undefined;
|
|
1171
|
+
if (pingDone) {
|
|
1172
|
+
doneMsgId = await this.notify(doneText, {
|
|
1173
|
+
loud: true,
|
|
1174
|
+
replyTo: this.turnReplyTo,
|
|
1175
|
+
replyMarkup: switchKb,
|
|
919
1176
|
});
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1177
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1178
|
+
}
|
|
1179
|
+
if (!this.cancelled) {
|
|
1180
|
+
try {
|
|
1181
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
1182
|
+
autoQueue: this.foreground,
|
|
926
1183
|
});
|
|
1184
|
+
if (pingDone && sug.text !== doneText) {
|
|
1185
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1186
|
+
}
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
log.debug(`suggestions after recheck-fail Done failed: ${(e as Error).message}`);
|
|
927
1189
|
}
|
|
928
|
-
} else if (pingDone) {
|
|
929
|
-
await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
930
1190
|
}
|
|
931
1191
|
this.preRecheckFileOps = new Map();
|
|
932
1192
|
} else {
|
|
933
1193
|
const transient = isTransientError(final.error);
|
|
934
1194
|
const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
935
|
-
|
|
1195
|
+
this.turnExpectDone = canPing;
|
|
1196
|
+
if (canPing) {
|
|
1197
|
+
const id = await this.notify(liveMsg, {
|
|
1198
|
+
loud: true,
|
|
1199
|
+
replyTo: this.turnReplyTo,
|
|
1200
|
+
replyMarkup: switchKb,
|
|
1201
|
+
});
|
|
1202
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1203
|
+
}
|
|
936
1204
|
}
|
|
937
1205
|
}
|
|
938
1206
|
} catch (err) {
|
|
939
1207
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
940
1208
|
await this.streamer?.finalize().catch(() => {});
|
|
941
1209
|
const errMsg = (err as Error).message;
|
|
942
|
-
this.
|
|
943
|
-
|
|
944
|
-
userText: this.turnUserText,
|
|
945
|
-
assistantText: this.turnAssistantText,
|
|
946
|
-
fileOps: this.fileOps,
|
|
947
|
-
error: errMsg,
|
|
948
|
-
}),
|
|
949
|
-
);
|
|
1210
|
+
this.persistCardUserPrompt();
|
|
1211
|
+
this.cardThinking = "";
|
|
950
1212
|
this.setLiveStep(undefined);
|
|
951
1213
|
// If the self-recheck pass itself blew up, still surface Done for the
|
|
952
1214
|
// original work (split files + suggestions) so the user is not stuck.
|
|
953
1215
|
if (this.isSelfRecheckTurn && this.queue.length === 0) {
|
|
954
1216
|
const switchKb = this.switchKeyboard();
|
|
955
1217
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
1218
|
+
this.turnExpectDone = canPing;
|
|
956
1219
|
let doneText =
|
|
957
1220
|
this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
|
|
958
1221
|
`\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
|
|
959
1222
|
try {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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, {
|
|
1223
|
+
let doneMsgId: number | undefined;
|
|
1224
|
+
if (canPing) {
|
|
1225
|
+
doneMsgId = await this.notify(doneText, {
|
|
974
1226
|
loud: true,
|
|
975
1227
|
replyTo: this.turnReplyTo,
|
|
976
1228
|
replyMarkup: switchKb,
|
|
977
1229
|
});
|
|
1230
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1231
|
+
}
|
|
1232
|
+
if (!this.cancelled) {
|
|
1233
|
+
try {
|
|
1234
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
1235
|
+
autoQueue: this.foreground,
|
|
1236
|
+
});
|
|
1237
|
+
if (canPing && sug.text !== doneText) {
|
|
1238
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1239
|
+
}
|
|
1240
|
+
} catch (e2) {
|
|
1241
|
+
log.debug(`suggestions after recheck catch failed: ${(e2 as Error).message}`);
|
|
1242
|
+
}
|
|
978
1243
|
}
|
|
979
1244
|
} catch (e2) {
|
|
980
1245
|
log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
|
|
981
|
-
if (canPing) {
|
|
982
|
-
await this.notify(doneText, {
|
|
1246
|
+
if (canPing && !this.turnDonePinged) {
|
|
1247
|
+
const id = await this.notify(doneText, {
|
|
983
1248
|
loud: true,
|
|
984
1249
|
replyTo: this.turnReplyTo,
|
|
985
1250
|
replyMarkup: switchKb,
|
|
986
|
-
})
|
|
1251
|
+
});
|
|
1252
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
987
1253
|
}
|
|
988
1254
|
}
|
|
989
1255
|
this.preRecheckFileOps = new Map();
|
|
990
1256
|
} else {
|
|
991
1257
|
const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
|
|
992
1258
|
this.lastCompletion = msg;
|
|
993
|
-
|
|
1259
|
+
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
1260
|
+
this.turnExpectDone = canPing;
|
|
1261
|
+
if (canPing) {
|
|
994
1262
|
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
995
|
-
await this.notify(`${from}${msg}`, {
|
|
1263
|
+
const id = await this.notify(`${from}${msg}`, {
|
|
996
1264
|
loud: true,
|
|
997
1265
|
replyTo: this.turnReplyTo,
|
|
998
1266
|
replyMarkup: this.switchKeyboard(),
|
|
999
1267
|
});
|
|
1268
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1000
1269
|
}
|
|
1001
1270
|
}
|
|
1002
1271
|
} finally {
|
|
1272
|
+
// Safety net: turn completed with an expected Done ping that never landed
|
|
1273
|
+
// (notify failed, hung path, etc.). Never block queue flush on this.
|
|
1274
|
+
if (this.turnExpectDone && !this.turnDonePinged) {
|
|
1275
|
+
const fallback =
|
|
1276
|
+
this.lastCompletion?.trim() ||
|
|
1277
|
+
`\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
|
|
1278
|
+
const short =
|
|
1279
|
+
fallback.length > 3500 ? fallback.slice(0, 3499) + "\u2026" : fallback;
|
|
1280
|
+
try {
|
|
1281
|
+
const id = await this.notify(short, {
|
|
1282
|
+
loud: true,
|
|
1283
|
+
replyTo: this.turnReplyTo,
|
|
1284
|
+
replyMarkup: this.switchKeyboard(),
|
|
1285
|
+
});
|
|
1286
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1287
|
+
else log.warn(`chat ${this.chatId}: Done safety-net notify failed`);
|
|
1288
|
+
} catch (e) {
|
|
1289
|
+
log.warn(`chat ${this.chatId}: Done safety-net error: ${(e as Error).message}`);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
this.turnExpectDone = false;
|
|
1003
1293
|
this.typing.stop();
|
|
1004
1294
|
this.streamer = undefined;
|
|
1005
1295
|
this.capturingQuiet = false;
|
|
@@ -1013,7 +1303,8 @@ export class SessionRuntime {
|
|
|
1013
1303
|
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
1014
1304
|
this.progress = undefined;
|
|
1015
1305
|
this.planEntries = undefined;
|
|
1016
|
-
//
|
|
1306
|
+
// Idle cards show last user prompt only (clear live step / thinking).
|
|
1307
|
+
this.cardThinking = "";
|
|
1017
1308
|
if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
|
|
1018
1309
|
this.changed();
|
|
1019
1310
|
}
|
|
@@ -1029,6 +1320,95 @@ export class SessionRuntime {
|
|
|
1029
1320
|
}
|
|
1030
1321
|
}
|
|
1031
1322
|
|
|
1323
|
+
/**
|
|
1324
|
+
* Parse telegram JSON actions from the assistant reply, execute them, notify
|
|
1325
|
+
* the user, and queue a results follow-up for the agent when useful.
|
|
1326
|
+
* Returns true when a results prompt was queued (delay Done/recheck).
|
|
1327
|
+
*/
|
|
1328
|
+
private async processTelegramBridgeActions(): Promise<boolean> {
|
|
1329
|
+
const { actions, cleaned } = extractTelegramActions(this.turnAssistantText);
|
|
1330
|
+
if (cleaned !== this.turnAssistantText) {
|
|
1331
|
+
this.turnAssistantText = cleaned;
|
|
1332
|
+
}
|
|
1333
|
+
if (actions.length === 0) return false;
|
|
1334
|
+
if (!this.bridge) {
|
|
1335
|
+
log.warn(`chat ${this.chatId}: telegram actions present but bridge not wired`);
|
|
1336
|
+
return false;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const botCmds = actions.filter((a) => a.action === "bot_command");
|
|
1340
|
+
// bot_command means Grok ended its turn early to wait on a sibling bot —
|
|
1341
|
+
// this is NOT a Done. Keep busy; status panel live-step only (no chat spam).
|
|
1342
|
+
if (botCmds.length > 0) {
|
|
1343
|
+
const labels = botCmds
|
|
1344
|
+
.map((a) =>
|
|
1345
|
+
a.action === "bot_command" ? `@${a.bot} /${a.command}` : "",
|
|
1346
|
+
)
|
|
1347
|
+
.filter(Boolean)
|
|
1348
|
+
.join(", ");
|
|
1349
|
+
this.setLiveStep(`Waiting for sibling bot: ${labels}`);
|
|
1350
|
+
} else {
|
|
1351
|
+
this.setLiveStep("Running Telegram bridge actions\u2026");
|
|
1352
|
+
}
|
|
1353
|
+
this.changed();
|
|
1354
|
+
log.info(`chat ${this.chatId}: executing ${actions.length} telegram bridge action(s)`);
|
|
1355
|
+
|
|
1356
|
+
const results = await executeTelegramActions(actions, {
|
|
1357
|
+
api: this.api,
|
|
1358
|
+
cfg: this.cfg,
|
|
1359
|
+
chatId: this.chatId,
|
|
1360
|
+
messageThreadId: this.messageThreadId,
|
|
1361
|
+
forum: this.bridge.forum,
|
|
1362
|
+
store: this.bridge.store,
|
|
1363
|
+
bots: this.bridge.bots,
|
|
1364
|
+
submitTopicPrompt: this.bridge.submitTopicPrompt,
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
// Only announce durable side-effects in chat (topic create/bind/cross-prompt).
|
|
1368
|
+
// search_memory / list_bots / bot wait status stay silent — results go to the agent.
|
|
1369
|
+
const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
|
|
1370
|
+
const notes = results
|
|
1371
|
+
.filter((r) => durableActions.has(r.action) && r.userNote?.trim())
|
|
1372
|
+
.map((r) => r.userNote!)
|
|
1373
|
+
.filter(Boolean);
|
|
1374
|
+
if (notes.length > 0 && this.foreground) {
|
|
1375
|
+
await this.notify(notes.join("\n"), {
|
|
1376
|
+
loud: true,
|
|
1377
|
+
replyTo: this.turnReplyTo,
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// Cap chained result turns so a model that re-emits list_bots forever cannot
|
|
1382
|
+
// block Done. Side-effects already ran; user notes were sent above.
|
|
1383
|
+
// Still queue once when we have bot_command errors so the agent can recover.
|
|
1384
|
+
if (this.bridgeResultDepth >= SessionRuntime.BRIDGE_CHAIN_MAX) {
|
|
1385
|
+
log.warn(
|
|
1386
|
+
`chat ${this.chatId}: telegram bridge chain depth ${this.bridgeResultDepth} — not re-queuing results`,
|
|
1387
|
+
);
|
|
1388
|
+
return false;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
// Feed results back so the agent can use search hits / bot replies / errors.
|
|
1392
|
+
const prompt = buildTelegramBridgeResultsPrompt(
|
|
1393
|
+
results.map((r) => ({
|
|
1394
|
+
action: r.action,
|
|
1395
|
+
ok: r.ok,
|
|
1396
|
+
data: r.data,
|
|
1397
|
+
error: r.error,
|
|
1398
|
+
})),
|
|
1399
|
+
);
|
|
1400
|
+
this.bridgeResultDepth++;
|
|
1401
|
+
this.queue.unshift(
|
|
1402
|
+
textPrompt(prompt, this.turnReplyTo, undefined, {
|
|
1403
|
+
skipSelfRecheck: true,
|
|
1404
|
+
promptId: this.turnPromptId,
|
|
1405
|
+
}),
|
|
1406
|
+
);
|
|
1407
|
+
this.setLiveStep("Feeding sibling-bot / bridge results to the agent\u2026");
|
|
1408
|
+
this.changed();
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1032
1412
|
/**
|
|
1033
1413
|
* Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
|
|
1034
1414
|
* for switch-replay, and optionally queue auto-approved items as **one**
|
|
@@ -1073,7 +1453,12 @@ export class SessionRuntime {
|
|
|
1073
1453
|
banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
|
|
1074
1454
|
// Single queue entry — agent executes 1) 2) 3) in one turn.
|
|
1075
1455
|
// skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
|
|
1076
|
-
this.queue.push(
|
|
1456
|
+
this.queue.push(
|
|
1457
|
+
textPrompt(batched, this.turnReplyTo, undefined, {
|
|
1458
|
+
skipSelfRecheck: true,
|
|
1459
|
+
promptId: this.turnPromptId,
|
|
1460
|
+
}),
|
|
1461
|
+
);
|
|
1077
1462
|
this.changed();
|
|
1078
1463
|
} else {
|
|
1079
1464
|
text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
@@ -1142,14 +1527,12 @@ export class SessionRuntime {
|
|
|
1142
1527
|
): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
|
|
1143
1528
|
if (!this.sessionId) return { needed: false, reason: "no session" };
|
|
1144
1529
|
const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
|
|
1145
|
-
this.capturingQuiet = true;
|
|
1146
|
-
this.quietCaptureBuf = "";
|
|
1147
1530
|
try {
|
|
1148
|
-
await this.
|
|
1149
|
-
return parseSelfRecheckDecision(
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1531
|
+
const raw = await this.runQuietPrompt(prompt);
|
|
1532
|
+
return parseSelfRecheckDecision(raw);
|
|
1533
|
+
} catch (e) {
|
|
1534
|
+
log.debug(`self-recheck decision quiet prompt failed: ${(e as Error).message}`);
|
|
1535
|
+
return { needed: false, reason: "decision prompt failed" };
|
|
1153
1536
|
}
|
|
1154
1537
|
}
|
|
1155
1538
|
|
|
@@ -1164,15 +1547,60 @@ export class SessionRuntime {
|
|
|
1164
1547
|
const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
|
|
1165
1548
|
const did = didParts.join("\n") || this.turnAssistantText;
|
|
1166
1549
|
const prompt = buildSuggestionsPrompt(user, did);
|
|
1550
|
+
try {
|
|
1551
|
+
const raw = await this.runQuietPrompt(prompt);
|
|
1552
|
+
return parseSuggestions(raw);
|
|
1553
|
+
} catch (e) {
|
|
1554
|
+
log.debug(`suggestions quiet prompt failed: ${(e as Error).message}`);
|
|
1555
|
+
return [];
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Run a quiet meta ACP prompt (JSON only) with a hard timeout.
|
|
1561
|
+
* On timeout: session/cancel so the shared agent is not stuck holding the
|
|
1562
|
+
* session (which would block Done forever). Does NOT set this.cancelled
|
|
1563
|
+
* (user /stop is separate). Timed-out / partial capture is discarded.
|
|
1564
|
+
*/
|
|
1565
|
+
private async runQuietPrompt(text: string): Promise<string> {
|
|
1566
|
+
if (!this.sessionId) return "";
|
|
1567
|
+
const ms = Math.max(5_000, this.cfg.quietPromptTimeoutMs || 90_000);
|
|
1167
1568
|
this.capturingQuiet = true;
|
|
1168
1569
|
this.quietCaptureBuf = "";
|
|
1570
|
+
let timedOut = false;
|
|
1571
|
+
let settled = false;
|
|
1572
|
+
const timer = setTimeout(() => {
|
|
1573
|
+
// Ignore timer if the prompt already settled (avoids discarding a good JSON
|
|
1574
|
+
// reply that finished in the same tick as the timeout).
|
|
1575
|
+
if (settled) return;
|
|
1576
|
+
timedOut = true;
|
|
1577
|
+
log.warn(
|
|
1578
|
+
`chat ${this.chatId}: quiet meta prompt timed out after ${ms}ms — cancelling session prompt`,
|
|
1579
|
+
);
|
|
1580
|
+
// Session-scoped cancel only — never kill the shared agent process.
|
|
1581
|
+
void this.acp.cancel(this.sessionId!);
|
|
1582
|
+
}, ms);
|
|
1583
|
+
let buf = "";
|
|
1169
1584
|
try {
|
|
1170
|
-
await this.acp.prompt(this.sessionId, [{ type: "text", text
|
|
1171
|
-
|
|
1585
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text }]);
|
|
1586
|
+
settled = true;
|
|
1587
|
+
clearTimeout(timer);
|
|
1588
|
+
buf = this.quietCaptureBuf;
|
|
1589
|
+
} catch (e) {
|
|
1590
|
+
settled = true;
|
|
1591
|
+
clearTimeout(timer);
|
|
1592
|
+
buf = this.quietCaptureBuf;
|
|
1593
|
+
if (!timedOut) throw e;
|
|
1594
|
+
log.debug(`quiet prompt ended after timeout: ${(e as Error).message}`);
|
|
1172
1595
|
} finally {
|
|
1596
|
+
clearTimeout(timer);
|
|
1173
1597
|
this.capturingQuiet = false;
|
|
1174
1598
|
this.quietCaptureBuf = "";
|
|
1175
1599
|
}
|
|
1600
|
+
// Timed-out meta replies are often half-JSON — skip rather than act on garbage.
|
|
1601
|
+
// If we settled successfully before the timer fired, timedOut stays false.
|
|
1602
|
+
if (timedOut) return "";
|
|
1603
|
+
return buf;
|
|
1176
1604
|
}
|
|
1177
1605
|
|
|
1178
1606
|
/** Resolve a tapped suggestion button; returns the prompt text or undefined. */
|
|
@@ -1479,6 +1907,11 @@ export class SessionRuntime {
|
|
|
1479
1907
|
try {
|
|
1480
1908
|
const updatesBeforePrompt = this.sessionUpdateCount;
|
|
1481
1909
|
const result = await this.acp.prompt(this.sessionId!, content);
|
|
1910
|
+
// User /stop force-complete or agent honouring session/cancel often
|
|
1911
|
+
// returns cancelled with zero session/update chunks — that is success.
|
|
1912
|
+
if (this.cancelled || result?.stopReason === "cancelled") {
|
|
1913
|
+
return { result: result ?? { stopReason: "cancelled" }, attempts: attempt };
|
|
1914
|
+
}
|
|
1482
1915
|
// A healthy ACP turn emits at least one session/update (text, thought,
|
|
1483
1916
|
// or tool event) before resolving session/prompt. Grok can otherwise
|
|
1484
1917
|
// report a successful end-turn after an upstream model failure; never
|
|
@@ -1604,6 +2037,7 @@ export class SessionRuntime {
|
|
|
1604
2037
|
already: this.sentImagesThisTurn,
|
|
1605
2038
|
max: this.cfg.agentImagesMax,
|
|
1606
2039
|
replyTo: this.turnReplyTo,
|
|
2040
|
+
messageThreadId: this.messageThreadId,
|
|
1607
2041
|
});
|
|
1608
2042
|
if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
|
|
1609
2043
|
} catch {
|
|
@@ -1693,12 +2127,13 @@ export class SessionRuntime {
|
|
|
1693
2127
|
}
|
|
1694
2128
|
|
|
1695
2129
|
/** Searchable Telegram hashtags so you can pull up every message of a session
|
|
1696
|
-
* or project by tapping the tag. */
|
|
2130
|
+
* or project (and this turn's prompt) by tapping the tag. */
|
|
1697
2131
|
private hashtags(): string {
|
|
1698
2132
|
return sessionHashtags({
|
|
1699
2133
|
projectName: this.projectName,
|
|
1700
2134
|
cwd: this.cwd,
|
|
1701
2135
|
sessionId: this.sessionId,
|
|
2136
|
+
promptId: this.turnPromptId,
|
|
1702
2137
|
});
|
|
1703
2138
|
}
|
|
1704
2139
|
|
|
@@ -1710,11 +2145,17 @@ export class SessionRuntime {
|
|
|
1710
2145
|
const head = this.queue[0]!;
|
|
1711
2146
|
const isMeta =
|
|
1712
2147
|
!!head.skipSelfRecheck ||
|
|
1713
|
-
isSelfRecheckPrompt(head.text)
|
|
2148
|
+
isSelfRecheckPrompt(head.text) ||
|
|
2149
|
+
isTelegramBridgeResultsPrompt(head.text);
|
|
1714
2150
|
const batch = isMeta
|
|
1715
2151
|
? this.queue.shift()!
|
|
1716
2152
|
: mergeInputs(this.queue.splice(0, this.queue.length));
|
|
1717
|
-
|
|
2153
|
+
// Real user follow-ups only — never spam chat for bridge/recheck meta turns.
|
|
2154
|
+
if (this.foreground && !isMeta) {
|
|
2155
|
+
await this.notify("\u25B6\uFE0F Processing queued message\u2026", {
|
|
2156
|
+
replyTo: batch.replyTo,
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
1718
2159
|
void this.runTurn(batch);
|
|
1719
2160
|
}
|
|
1720
2161
|
|
|
@@ -1761,7 +2202,10 @@ export class SessionRuntime {
|
|
|
1761
2202
|
}
|
|
1762
2203
|
} else if (kind === "agent_thought_chunk") {
|
|
1763
2204
|
const text = contentText(update.content);
|
|
1764
|
-
if (text?.trim())
|
|
2205
|
+
if (text?.trim()) {
|
|
2206
|
+
this.appendCardThinking(text);
|
|
2207
|
+
this.setLiveStep(stepFromThought(text));
|
|
2208
|
+
}
|
|
1765
2209
|
} else if (kind === "plan") {
|
|
1766
2210
|
// Always track plan entries (background too) so switch-to-live restores the board.
|
|
1767
2211
|
const entries = parsePlanUpdate(update);
|
|
@@ -1836,7 +2280,7 @@ export class SessionRuntime {
|
|
|
1836
2280
|
|
|
1837
2281
|
private persist(): void {
|
|
1838
2282
|
if (!this.foreground) return; // only the foreground session is the chat's restored default
|
|
1839
|
-
this.settings.
|
|
2283
|
+
this.settings.updateKey(this.settingsKey, {
|
|
1840
2284
|
projectPath: this.cwd,
|
|
1841
2285
|
projectName: this.projectName,
|
|
1842
2286
|
sessionId: this.sessionId,
|
|
@@ -1859,20 +2303,61 @@ export class SessionRuntime {
|
|
|
1859
2303
|
}
|
|
1860
2304
|
}
|
|
1861
2305
|
|
|
2306
|
+
/**
|
|
2307
|
+
* Send a chat message. Returns Telegram message_id on success.
|
|
2308
|
+
* On failure, retries once truncated (~3500) without reply_markup so a long
|
|
2309
|
+
* Done / markup error cannot silently drop the completion ping.
|
|
2310
|
+
*/
|
|
1862
2311
|
private async notify(
|
|
1863
2312
|
text: string,
|
|
1864
2313
|
opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
|
|
2314
|
+
): Promise<number | undefined> {
|
|
2315
|
+
const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
|
|
2316
|
+
try {
|
|
2317
|
+
const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
|
|
2318
|
+
if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
|
|
2319
|
+
if (opts?.replyTo !== undefined) {
|
|
2320
|
+
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
2321
|
+
}
|
|
2322
|
+
if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
2323
|
+
const msg = await this.api.sendMessage(this.chatId, body, extra);
|
|
2324
|
+
return msg.message_id;
|
|
2325
|
+
} catch (e) {
|
|
2326
|
+
log.debug("notify failed:", (e as Error).message);
|
|
2327
|
+
return undefined;
|
|
2328
|
+
}
|
|
2329
|
+
};
|
|
2330
|
+
const id = await send(text, true);
|
|
2331
|
+
if (id !== undefined) return id;
|
|
2332
|
+
const short = text.length > 3500 ? text.slice(0, 3499) + "\u2026" : text;
|
|
2333
|
+
return send(short, false);
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
/**
|
|
2337
|
+
* After Done was already sent, attach suggestion text + buttons by editing
|
|
2338
|
+
* that message (or sending a follow-up if edit fails / no message id).
|
|
2339
|
+
*/
|
|
2340
|
+
private async enhanceDoneMessage(
|
|
2341
|
+
messageId: number | undefined,
|
|
2342
|
+
text: string,
|
|
2343
|
+
markup: InlineKeyboard | undefined,
|
|
1865
2344
|
): Promise<void> {
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
2345
|
+
if (messageId !== undefined) {
|
|
2346
|
+
try {
|
|
2347
|
+
const extra: Record<string, unknown> = {};
|
|
2348
|
+
if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
|
|
2349
|
+
if (markup) extra.reply_markup = markup;
|
|
2350
|
+
await this.api.editMessageText(this.chatId, messageId, text, extra);
|
|
2351
|
+
return;
|
|
2352
|
+
} catch (e) {
|
|
2353
|
+
log.debug("enhanceDoneMessage edit failed:", (e as Error).message);
|
|
1870
2354
|
}
|
|
1871
|
-
if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
1872
|
-
await this.api.sendMessage(this.chatId, text, extra);
|
|
1873
|
-
} catch (e) {
|
|
1874
|
-
log.debug("notify failed:", (e as Error).message);
|
|
1875
2355
|
}
|
|
2356
|
+
await this.notify(text, {
|
|
2357
|
+
loud: false,
|
|
2358
|
+
replyTo: this.turnReplyTo,
|
|
2359
|
+
replyMarkup: markup,
|
|
2360
|
+
});
|
|
1876
2361
|
}
|
|
1877
2362
|
|
|
1878
2363
|
private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
|
|
@@ -1885,7 +2370,11 @@ export class SessionRuntime {
|
|
|
1885
2370
|
})
|
|
1886
2371
|
.filter(Boolean)
|
|
1887
2372
|
.join("\n\n");
|
|
1888
|
-
if (body.trim())
|
|
2373
|
+
if (body.trim()) {
|
|
2374
|
+
await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`, {
|
|
2375
|
+
messageThreadId: this.messageThreadId,
|
|
2376
|
+
});
|
|
2377
|
+
}
|
|
1889
2378
|
}
|
|
1890
2379
|
}
|
|
1891
2380
|
|