grok-telegram-bot 2.4.0 → 2.6.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 +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -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 +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -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 +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -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 +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -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 +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -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/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -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 +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- 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
|
@@ -23,7 +23,7 @@ import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types
|
|
|
23
23
|
import { createLogger } from "../logger.js";
|
|
24
24
|
import { buildTranscript, readHistory } from "../sessions/history.js";
|
|
25
25
|
import { sessionHashtags } from "../render/hashtags.js";
|
|
26
|
-
import { PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
26
|
+
import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
|
|
27
27
|
import { buildPriming, recentTranscript } from "./session-fork.js";
|
|
28
28
|
import { TailWatcher } from "../sessions/tail.js";
|
|
29
29
|
import type { HistoryEntry } from "../sessions/types.js";
|
|
@@ -62,6 +62,33 @@ import {
|
|
|
62
62
|
type Suggestion,
|
|
63
63
|
suggestionsKeyboard,
|
|
64
64
|
} from "./suggestions.js";
|
|
65
|
+
import type { ForumManager } from "../forum/manager.js";
|
|
66
|
+
import { isGeneralThread, outboundThreadExtra } from "../forum/thread.js";
|
|
67
|
+
import type { SessionStore } from "../sessions/store.js";
|
|
68
|
+
import type { TelegramBotService } from "./telegram-bots.js";
|
|
69
|
+
import { executeTelegramActions } from "./telegram-actions.js";
|
|
70
|
+
import {
|
|
71
|
+
buildManagerContextBlock,
|
|
72
|
+
injectManagerContext,
|
|
73
|
+
} from "./manager-context.js";
|
|
74
|
+
import {
|
|
75
|
+
bindJobSession,
|
|
76
|
+
updateManagerJob,
|
|
77
|
+
type ReportBackMeta,
|
|
78
|
+
} from "./manager-jobs.js";
|
|
79
|
+
import {
|
|
80
|
+
buildManagerWorkReportPrompt,
|
|
81
|
+
isManagerWorkReportPrompt,
|
|
82
|
+
wrapManagerDirective,
|
|
83
|
+
} from "../render/manager-directive.js";
|
|
84
|
+
import {
|
|
85
|
+
buildTelegramBridgeDirective,
|
|
86
|
+
buildTelegramBridgeResultsPrompt,
|
|
87
|
+
extractTelegramActions,
|
|
88
|
+
isTelegramBridgeResultsPrompt,
|
|
89
|
+
stripTelegramActionFences,
|
|
90
|
+
wrapTelegramBridgePrompt,
|
|
91
|
+
} from "../render/telegram-bridge.js";
|
|
65
92
|
import {
|
|
66
93
|
parsePlanUpdate,
|
|
67
94
|
renderPlanMarkdown,
|
|
@@ -69,9 +96,11 @@ import {
|
|
|
69
96
|
type PlanEntry,
|
|
70
97
|
} from "../render/plan.js";
|
|
71
98
|
import {
|
|
72
|
-
|
|
99
|
+
buildSessionCardComment,
|
|
100
|
+
clampThinking,
|
|
73
101
|
cleanCommentLine,
|
|
74
102
|
cleanUserPreview,
|
|
103
|
+
COMMENT_MAX,
|
|
75
104
|
stepFromThought,
|
|
76
105
|
stepFromToolUpdate,
|
|
77
106
|
stripDirectiveWrappers,
|
|
@@ -144,6 +173,8 @@ export class SessionRuntime {
|
|
|
144
173
|
private turnCount = 0;
|
|
145
174
|
/** Telegram message id of the current turn's prompt, so replies thread to it. */
|
|
146
175
|
private turnReplyTo: number | undefined;
|
|
176
|
+
/** Short id for `#prompt_<id>` on all AI messages of this turn. */
|
|
177
|
+
private turnPromptId: string | undefined;
|
|
147
178
|
private imageScanText = "";
|
|
148
179
|
private sentImagesThisTurn = new Set<string>();
|
|
149
180
|
/** Monotonic count used to reject ACP "success" responses with no turn updates. */
|
|
@@ -172,19 +203,77 @@ export class SessionRuntime {
|
|
|
172
203
|
accountRotator: AccountRotator | undefined;
|
|
173
204
|
/** Session ids that already received the first-prompt auto-complexity directive. */
|
|
174
205
|
private complexitySteered = new Set<string>();
|
|
206
|
+
/** Session ids that already received the first-prompt Telegram bridge directive. */
|
|
207
|
+
private telegramBridgeSteered = new Set<string>();
|
|
208
|
+
/**
|
|
209
|
+
* How many TELEGRAM BRIDGE RESULTS follow-ups are chained after the current
|
|
210
|
+
* user turn. Caps infinite list_bots/bot_command loops; reset on real user work.
|
|
211
|
+
*/
|
|
212
|
+
private bridgeResultDepth = 0;
|
|
213
|
+
/** Max sequential bridge result turns per user request. */
|
|
214
|
+
private static readonly BRIDGE_CHAIN_MAX = 4;
|
|
215
|
+
/**
|
|
216
|
+
* Optional Telegram bridge services (forum / session store / sibling bots).
|
|
217
|
+
* Injected by the registry after construct.
|
|
218
|
+
*/
|
|
219
|
+
bridge?: {
|
|
220
|
+
store: SessionStore;
|
|
221
|
+
forum?: ForumManager;
|
|
222
|
+
bots: TelegramBotService;
|
|
223
|
+
/** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
|
|
224
|
+
submitTopicPrompt?: import("./telegram-actions.js").SubmitTopicPromptFn;
|
|
225
|
+
/** Wake General manager with a work-report prompt. */
|
|
226
|
+
wakeManager?: (opts: {
|
|
227
|
+
originChatId: number;
|
|
228
|
+
originThreadId: number;
|
|
229
|
+
prompt: string;
|
|
230
|
+
}) => Promise<void>;
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* Report-back for the *current* turn chain (dispatch + recheck/suggestions).
|
|
234
|
+
* Set from PromptInput.reportBack at turn start; kept until queue drains.
|
|
235
|
+
*/
|
|
236
|
+
private pendingReportBack: ReportBackMeta | undefined;
|
|
237
|
+
/**
|
|
238
|
+
* Staged by {@link setReportBack} and attached to the next {@link submit}
|
|
239
|
+
* so concurrent dispatches carry their own job through the queue.
|
|
240
|
+
*/
|
|
241
|
+
private stagedReportBack: ReportBackMeta | undefined;
|
|
175
242
|
/** Last credits total reported for this session (for per-turn delta accounting). */
|
|
176
243
|
private lastReportedCredits = 0;
|
|
177
|
-
/** Live "what is happening now" line while a turn is in flight. */
|
|
244
|
+
/** Live "what is happening now" line while a turn is in flight (tools/plan). */
|
|
178
245
|
private liveStep: string | undefined;
|
|
179
|
-
/**
|
|
246
|
+
/**
|
|
247
|
+
* Card comment on disk / idle: last user prompt (≤ COMMENT_MAX).
|
|
248
|
+
* While busy, {@link cardComment} also appends last agent thinking.
|
|
249
|
+
*/
|
|
180
250
|
private sessionComment: string | undefined;
|
|
251
|
+
/** Cleaned last user prompt for cards (not overwritten by self-recheck meta). */
|
|
252
|
+
private cardUserPrompt: string | undefined;
|
|
253
|
+
/** Accumulated agent_thought_chunk text for the current turn (card display). */
|
|
254
|
+
private cardThinking = "";
|
|
181
255
|
/** User text of the turn currently running (for local card-comment fallback). */
|
|
182
256
|
private turnUserText = "";
|
|
183
|
-
/** Assistant prose streamed this turn — used
|
|
257
|
+
/** Assistant prose streamed this turn — used for suggestions / completion. */
|
|
184
258
|
private turnAssistantText = "";
|
|
185
259
|
/** Quiet meta capture (suggestions) — never stream to Telegram. */
|
|
186
260
|
private capturingQuiet = false;
|
|
187
261
|
private quietCaptureBuf = "";
|
|
262
|
+
/**
|
|
263
|
+
* General manager: Thinking… / Starting… bubble for this turn (deleted when
|
|
264
|
+
* silent, or replaced by a single fallback reply).
|
|
265
|
+
*/
|
|
266
|
+
private managerStatusMsgId: number | undefined;
|
|
267
|
+
/** Count of successful notify actions this turn (user-facing messages). */
|
|
268
|
+
private managerNotifyCount = 0;
|
|
269
|
+
/** True when this turn already delivered at least one user-visible message. */
|
|
270
|
+
private managerUserVisible = false;
|
|
271
|
+
/**
|
|
272
|
+
* Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
|
|
273
|
+
* one was successfully sent (finally forces a short Done if expected but missing).
|
|
274
|
+
*/
|
|
275
|
+
private turnExpectDone = false;
|
|
276
|
+
private turnDonePinged = false;
|
|
188
277
|
/** Batches of post-turn suggestions for inline-button callbacks. */
|
|
189
278
|
private suggestionBatches = new Map<number, Suggestion[]>();
|
|
190
279
|
private suggestionBatchSeq = 0;
|
|
@@ -214,20 +303,48 @@ export class SessionRuntime {
|
|
|
214
303
|
*/
|
|
215
304
|
private skipSelfRecheck = false;
|
|
216
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Forum topic thread id (message_thread_id). When set, all outbound messages
|
|
308
|
+
* for this runtime are posted into that topic.
|
|
309
|
+
*/
|
|
310
|
+
readonly messageThreadId: number | undefined;
|
|
311
|
+
/** Settings storage key (`chatId` or `chatId:t{threadId}`). */
|
|
312
|
+
readonly settingsKey: string;
|
|
313
|
+
/**
|
|
314
|
+
* General topic only: OpenClaw-style manager chat (orchestrate, no coding UX).
|
|
315
|
+
*/
|
|
316
|
+
readonly managerMode: boolean;
|
|
317
|
+
/**
|
|
318
|
+
* Optional: register Telegram message id → session for General reply-routing
|
|
319
|
+
* (user message, Done, stream bubbles). Wired by ChatController.
|
|
320
|
+
*/
|
|
321
|
+
onTelegramMessageBound: ((messageId: number, sessionId: string) => void) | undefined;
|
|
322
|
+
|
|
217
323
|
constructor(
|
|
218
324
|
private readonly api: Api,
|
|
219
325
|
private readonly chatId: number,
|
|
220
326
|
private readonly acp: GrokClient,
|
|
221
327
|
private readonly cfg: AppConfig,
|
|
222
328
|
private readonly settings: SettingsStore,
|
|
223
|
-
init?: {
|
|
329
|
+
init?: {
|
|
330
|
+
cwd: string;
|
|
331
|
+
projectName?: string;
|
|
332
|
+
sessionId?: string;
|
|
333
|
+
messageThreadId?: number;
|
|
334
|
+
settingsKey?: string;
|
|
335
|
+
},
|
|
224
336
|
) {
|
|
337
|
+
this.messageThreadId = init?.messageThreadId;
|
|
338
|
+
this.settingsKey = init?.settingsKey ?? String(chatId);
|
|
339
|
+
// Only the forum General topic is the manager — not AI Chat / private DMs.
|
|
340
|
+
this.managerMode =
|
|
341
|
+
this.messageThreadId !== undefined && isGeneralThread(this.messageThreadId);
|
|
225
342
|
if (init) {
|
|
226
343
|
this.cwd = init.cwd;
|
|
227
344
|
this.projectName = init.projectName;
|
|
228
345
|
this.sessionId = init.sessionId;
|
|
229
346
|
} else {
|
|
230
|
-
const s = settings.
|
|
347
|
+
const s = settings.getKey(this.settingsKey);
|
|
231
348
|
this.cwd = s.projectPath ?? cfg.workspace;
|
|
232
349
|
this.projectName = s.projectName;
|
|
233
350
|
this.sessionId = s.sessionId;
|
|
@@ -264,9 +381,27 @@ export class SessionRuntime {
|
|
|
264
381
|
|
|
265
382
|
/** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
|
|
266
383
|
get taskProgress(): number | undefined {
|
|
384
|
+
// Manager chat never shows a progress bar.
|
|
385
|
+
if (this.managerMode) return undefined;
|
|
267
386
|
return this.progress;
|
|
268
387
|
}
|
|
269
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Stage report-back for the next {@link submit} (General → project dispatch).
|
|
391
|
+
* Attached to that prompt so a second dispatch cannot steal the first job's
|
|
392
|
+
* completion report when the child session is busy/queued.
|
|
393
|
+
*/
|
|
394
|
+
setReportBack(meta: ReportBackMeta): void {
|
|
395
|
+
if (this.stagedReportBack && this.stagedReportBack.jobId !== meta.jobId) {
|
|
396
|
+
updateManagerJob(this.stagedReportBack.jobId, {
|
|
397
|
+
status: "cancelled",
|
|
398
|
+
resultSummary: "superseded by a newer manager dispatch before start",
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
this.stagedReportBack = meta;
|
|
402
|
+
if (this.sessionId) bindJobSession(meta.jobId, this.sessionId);
|
|
403
|
+
}
|
|
404
|
+
|
|
270
405
|
/**
|
|
271
406
|
* Full plan board for the live stream / status panel (above the progress bar).
|
|
272
407
|
* Empty when no plan is active this turn.
|
|
@@ -299,14 +434,23 @@ export class SessionRuntime {
|
|
|
299
434
|
}
|
|
300
435
|
|
|
301
436
|
/**
|
|
302
|
-
*
|
|
303
|
-
*
|
|
437
|
+
* Session card comment:
|
|
438
|
+
* always — last user prompt (≤250)
|
|
439
|
+
* busy — plus last AI agent thinking on the next line (≤250)
|
|
304
440
|
*/
|
|
305
441
|
get cardComment(): string | undefined {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
442
|
+
const user =
|
|
443
|
+
this.cardUserPrompt ||
|
|
444
|
+
this.sessionComment ||
|
|
445
|
+
(this.sessionId ? this.acp.sessionComment(this.sessionId) : undefined) ||
|
|
446
|
+
cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX) ||
|
|
447
|
+
undefined;
|
|
448
|
+
const built = buildSessionCardComment({
|
|
449
|
+
userPrompt: user,
|
|
450
|
+
thinking: this.busy && this.cardThinking ? this.cardThinking : undefined,
|
|
451
|
+
busy: this.busy,
|
|
452
|
+
});
|
|
453
|
+
return built || undefined;
|
|
310
454
|
}
|
|
311
455
|
|
|
312
456
|
/** Record a new progress value and refresh the status panel / cards. The bar
|
|
@@ -319,7 +463,7 @@ export class SessionRuntime {
|
|
|
319
463
|
this.changed();
|
|
320
464
|
}
|
|
321
465
|
|
|
322
|
-
/** Update the live step
|
|
466
|
+
/** Update the live step (tools/plan) — kept for diagnostics; cards use user+thinking. */
|
|
323
467
|
private setLiveStep(step: string | undefined): void {
|
|
324
468
|
const next = step?.trim() ? cleanCommentLine(step) : undefined;
|
|
325
469
|
if (next === this.liveStep) return;
|
|
@@ -327,9 +471,21 @@ export class SessionRuntime {
|
|
|
327
471
|
this.changed();
|
|
328
472
|
}
|
|
329
473
|
|
|
330
|
-
/**
|
|
474
|
+
/** Append thought text and refresh cards when the display line changes. */
|
|
475
|
+
private appendCardThinking(chunk: string): void {
|
|
476
|
+
const piece = chunk.replace(/\s+/g, " ").trim();
|
|
477
|
+
if (!piece) return;
|
|
478
|
+
const prevShown = this.cardThinking ? clampThinking(this.cardThinking, COMMENT_MAX) : "";
|
|
479
|
+
this.cardThinking = this.cardThinking ? `${this.cardThinking} ${piece}` : piece;
|
|
480
|
+
const nextShown = clampThinking(this.cardThinking, COMMENT_MAX);
|
|
481
|
+
if (nextShown !== prevShown) this.changed();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Persist last user prompt (disk + memory) so /running and /sessions see it. */
|
|
331
485
|
private setSessionComment(comment: string | undefined): void {
|
|
332
|
-
const next = comment?.trim()
|
|
486
|
+
const next = comment?.trim()
|
|
487
|
+
? cleanUserPreview(comment, COMMENT_MAX) || cleanCommentLine(comment, COMMENT_MAX)
|
|
488
|
+
: undefined;
|
|
333
489
|
if (next === this.sessionComment) return;
|
|
334
490
|
this.sessionComment = next;
|
|
335
491
|
if (next && this.sessionId) {
|
|
@@ -342,11 +498,25 @@ export class SessionRuntime {
|
|
|
342
498
|
this.changed();
|
|
343
499
|
}
|
|
344
500
|
|
|
345
|
-
/**
|
|
501
|
+
/** Keep disk/memory comment = last real user prompt after a turn ends. */
|
|
502
|
+
private persistCardUserPrompt(): void {
|
|
503
|
+
const prompt =
|
|
504
|
+
this.cardUserPrompt ||
|
|
505
|
+
cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX);
|
|
506
|
+
if (prompt) {
|
|
507
|
+
this.cardUserPrompt = prompt;
|
|
508
|
+
this.setSessionComment(prompt);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Hydrate last user prompt from disk after bind/resume. */
|
|
346
513
|
private loadPersistedComment(): void {
|
|
347
514
|
if (!this.sessionId) return;
|
|
348
515
|
const c = this.acp.sessionComment(this.sessionId);
|
|
349
|
-
if (c)
|
|
516
|
+
if (c) {
|
|
517
|
+
this.sessionComment = c;
|
|
518
|
+
if (!this.cardUserPrompt) this.cardUserPrompt = cleanUserPreview(c, COMMENT_MAX) || c;
|
|
519
|
+
}
|
|
350
520
|
}
|
|
351
521
|
|
|
352
522
|
/** Searchable hashtag footer for this session (project В· session В· model В·
|
|
@@ -364,12 +534,23 @@ export class SessionRuntime {
|
|
|
364
534
|
if (value) {
|
|
365
535
|
// A turn was started here and is still in flight, but its streamer was
|
|
366
536
|
// finalized when we went background. Recreate it and let onUpdate feed
|
|
367
|
-
// the remaining chunks/thoughts/tools just like a normal live turn
|
|
368
|
-
//
|
|
537
|
+
// the remaining chunks/thoughts/tools just like a normal live turn.
|
|
538
|
+
// Recreate the streamer for the live turn (manager: prose-only).
|
|
369
539
|
if (this.busy && !this.streamer) {
|
|
370
540
|
// Any transient follow-watch of this session is now superseded.
|
|
371
541
|
if (this.watchIsFollow) this.stopWatch();
|
|
372
|
-
this.streamer = new ResponseStreamer(
|
|
542
|
+
this.streamer = new ResponseStreamer(
|
|
543
|
+
this.api,
|
|
544
|
+
this.chatId,
|
|
545
|
+
this.cfg.streamThrottleMs,
|
|
546
|
+
this.turnReplyTo,
|
|
547
|
+
this.hashtags(),
|
|
548
|
+
this.managerMode ? undefined : (pct) => this.setProgress(pct),
|
|
549
|
+
this.managerMode ? false : this.cfg.progressFallback,
|
|
550
|
+
this.turnStartedAt,
|
|
551
|
+
this.messageThreadId,
|
|
552
|
+
this.managerMode ? { proseOnly: true, showProgressBar: false } : undefined,
|
|
553
|
+
);
|
|
373
554
|
// Restore the live plan board so steps stay visible above the progress bar.
|
|
374
555
|
if (this.planEntries?.length) {
|
|
375
556
|
this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
|
|
@@ -379,6 +560,7 @@ export class SessionRuntime {
|
|
|
379
560
|
} else {
|
|
380
561
|
this.typing.stop();
|
|
381
562
|
this.stopWatch();
|
|
563
|
+
// Seal live bubble when demoted (manager has no streamer).
|
|
382
564
|
if (this.streamer) {
|
|
383
565
|
// Finalize off the critical path so project/session switches never wait
|
|
384
566
|
// on Telegram edits of the previous live stream.
|
|
@@ -390,13 +572,16 @@ export class SessionRuntime {
|
|
|
390
572
|
this.changed();
|
|
391
573
|
}
|
|
392
574
|
get reasoning(): ReasoningEffort {
|
|
393
|
-
return this.settings.
|
|
575
|
+
return this.settings.getKey(this.settingsKey).reasoning;
|
|
394
576
|
}
|
|
395
577
|
get agent(): string | undefined {
|
|
396
|
-
return this.settings.
|
|
578
|
+
return this.settings.getKey(this.settingsKey).agent;
|
|
397
579
|
}
|
|
398
580
|
get model(): string | undefined {
|
|
399
|
-
return this.settings.
|
|
581
|
+
return this.settings.getKey(this.settingsKey).model;
|
|
582
|
+
}
|
|
583
|
+
get preferredAccountId(): string | undefined {
|
|
584
|
+
return this.settings.getKey(this.settingsKey).preferredAccountId;
|
|
400
585
|
}
|
|
401
586
|
|
|
402
587
|
/** Latest context-usage % / effort / credits for the current session. */
|
|
@@ -440,6 +625,8 @@ export class SessionRuntime {
|
|
|
440
625
|
this.lastReportedCredits = 0;
|
|
441
626
|
this.liveStep = undefined;
|
|
442
627
|
this.sessionComment = undefined;
|
|
628
|
+
this.cardUserPrompt = undefined;
|
|
629
|
+
this.cardThinking = "";
|
|
443
630
|
await this.applySessionPrefs();
|
|
444
631
|
this.persist();
|
|
445
632
|
this.sessionChanged();
|
|
@@ -495,8 +682,8 @@ export class SessionRuntime {
|
|
|
495
682
|
async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
|
|
496
683
|
await this.startNewSession(cwd, projectName);
|
|
497
684
|
if (priming.trim()) this.primingContext = priming;
|
|
498
|
-
// Imported transcripts already have context — skip first-prompt
|
|
499
|
-
this.
|
|
685
|
+
// Imported transcripts already have context — skip first-prompt directives.
|
|
686
|
+
this.markFirstPromptSteered();
|
|
500
687
|
}
|
|
501
688
|
|
|
502
689
|
startWatch(jsonlPath: string, follow = false): void {
|
|
@@ -519,7 +706,7 @@ export class SessionRuntime {
|
|
|
519
706
|
async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
|
|
520
707
|
// Persist the choice always; only talk to Grok when a session is live in
|
|
521
708
|
// the current process (set_model on an unloaded session crashes the agent).
|
|
522
|
-
this.settings.
|
|
709
|
+
this.settings.updateKey(this.settingsKey, { model: modelId });
|
|
523
710
|
if (modelId && this.sessionLive && this.sessionId) {
|
|
524
711
|
if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
|
|
525
712
|
try {
|
|
@@ -534,7 +721,7 @@ export class SessionRuntime {
|
|
|
534
721
|
}
|
|
535
722
|
|
|
536
723
|
async setAgentPref(agent: string): Promise<void> {
|
|
537
|
-
this.settings.
|
|
724
|
+
this.settings.updateKey(this.settingsKey, { agent });
|
|
538
725
|
if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
|
|
539
726
|
try {
|
|
540
727
|
await this.acp.setMode(this.sessionId, agent);
|
|
@@ -546,22 +733,29 @@ export class SessionRuntime {
|
|
|
546
733
|
}
|
|
547
734
|
|
|
548
735
|
setReasoningPref(effort: ReasoningEffort): void {
|
|
549
|
-
this.settings.
|
|
736
|
+
this.settings.updateKey(this.settingsKey, { reasoning: effort });
|
|
737
|
+
this.changed();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
setPreferredAccountId(id: string | undefined): void {
|
|
741
|
+
this.settings.updateKey(this.settingsKey, { preferredAccountId: id || undefined });
|
|
742
|
+
// Force re-apply on next ensureSession when user changes preference.
|
|
743
|
+
this.preferredAccountApplied = undefined;
|
|
550
744
|
this.changed();
|
|
551
745
|
}
|
|
552
746
|
|
|
553
747
|
private async applySessionPrefs(): Promise<void> {
|
|
554
|
-
const s = this.settings.
|
|
748
|
+
const s = this.settings.getKey(this.settingsKey);
|
|
555
749
|
// Drop any persisted model the agent doesn't actually offer (an unknown id
|
|
556
750
|
// is silently accepted by set_model but then breaks the next prompt).
|
|
557
751
|
if (s.model && !this.acp.hasModel(s.model)) {
|
|
558
|
-
log.warn(`clearing invalid persisted model "${s.model}" for
|
|
559
|
-
this.settings.
|
|
752
|
+
log.warn(`clearing invalid persisted model "${s.model}" for scope ${this.settingsKey}`);
|
|
753
|
+
this.settings.updateKey(this.settingsKey, { model: "" });
|
|
560
754
|
}
|
|
561
|
-
const cur = this.settings.
|
|
755
|
+
const cur = this.settings.getKey(this.settingsKey);
|
|
562
756
|
// Adopt the session's current agent (mode) when the user hasn't chosen one.
|
|
563
757
|
if (!cur.agent && this.acp.currentModeId) {
|
|
564
|
-
this.settings.
|
|
758
|
+
this.settings.updateKey(this.settingsKey, { agent: this.acp.currentModeId });
|
|
565
759
|
} else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
|
|
566
760
|
try {
|
|
567
761
|
await this.acp.setMode(this.sessionId, cur.agent);
|
|
@@ -582,40 +776,88 @@ export class SessionRuntime {
|
|
|
582
776
|
|
|
583
777
|
async submit(input: PromptInput): Promise<"ran" | "queued"> {
|
|
584
778
|
await this.ensureSession();
|
|
779
|
+
// Attach staged manager report-back to this prompt (FIFO through the queue).
|
|
780
|
+
let toSubmit = input;
|
|
781
|
+
if (this.stagedReportBack) {
|
|
782
|
+
const rb = this.stagedReportBack;
|
|
783
|
+
this.stagedReportBack = undefined;
|
|
784
|
+
toSubmit = input.reportBack ? input : { ...input, reportBack: rb };
|
|
785
|
+
if (this.sessionId) bindJobSession(rb.jobId, this.sessionId);
|
|
786
|
+
}
|
|
585
787
|
if (this.busy) {
|
|
586
|
-
this.queue.push(
|
|
788
|
+
this.queue.push(toSubmit);
|
|
587
789
|
this.changed();
|
|
588
790
|
return "queued";
|
|
589
791
|
}
|
|
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);
|
|
792
|
+
// First-prompt steering is applied inside runTurn so queued first messages
|
|
793
|
+
// (and flushQueue) get the same complexity + telegram bridge directives.
|
|
794
|
+
void this.runTurn(toSubmit);
|
|
599
795
|
return "ran";
|
|
600
796
|
}
|
|
601
797
|
|
|
602
|
-
private
|
|
603
|
-
if (this.sessionId)
|
|
798
|
+
private markFirstPromptSteered(): void {
|
|
799
|
+
if (!this.sessionId) return;
|
|
800
|
+
this.complexitySteered.add(this.sessionId);
|
|
801
|
+
this.telegramBridgeSteered.add(this.sessionId);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
private telegramBridgeDirective(): string {
|
|
805
|
+
return buildTelegramBridgeDirective({
|
|
806
|
+
forumReady: !!this.bridge?.forum?.isReady,
|
|
807
|
+
topicGroupId: this.cfg.topicGroupId,
|
|
808
|
+
allowedBots: this.cfg.allowedTelegramBots,
|
|
809
|
+
botCommands: this.cfg.telegramBotCommands,
|
|
810
|
+
managerMode: this.managerMode,
|
|
811
|
+
});
|
|
604
812
|
}
|
|
605
813
|
|
|
606
814
|
/**
|
|
607
|
-
*
|
|
608
|
-
* conversation (no prior user turns in this process / session jsonl).
|
|
815
|
+
* Complexity + telegram bridge teaching on the first prompt of a brand-new
|
|
816
|
+
* conversation only (no prior user turns in this process / session jsonl).
|
|
817
|
+
* Manager mode uses MANAGER_DIRECTIVE instead of complexity/progress coding UX.
|
|
609
818
|
*/
|
|
610
|
-
private
|
|
819
|
+
private applyFirstPromptSteering(input: PromptInput): PromptInput {
|
|
820
|
+
if (!this.shouldSteerFirstPrompt(input)) return input;
|
|
821
|
+
let toRun: PromptInput;
|
|
822
|
+
if (this.managerMode) {
|
|
823
|
+
toRun = wrapManagerDirective(input);
|
|
824
|
+
toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
|
|
825
|
+
this.markFirstPromptSteered();
|
|
826
|
+
log.info(`chat ${this.chatId}: first-prompt manager + telegram bridge applied`);
|
|
827
|
+
return toRun;
|
|
828
|
+
}
|
|
829
|
+
toRun = wrapAutoComplexityPrompt(input);
|
|
830
|
+
toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
|
|
831
|
+
this.markFirstPromptSteered();
|
|
832
|
+
log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
|
|
833
|
+
return toRun;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Apply first-prompt directives only on a brand-new conversation (no prior
|
|
838
|
+
* user turns in this process / session jsonl).
|
|
839
|
+
*/
|
|
840
|
+
private shouldSteerFirstPrompt(input: PromptInput): boolean {
|
|
611
841
|
if (!this.sessionId) return false;
|
|
612
|
-
if (this.complexitySteered.has(this.sessionId))
|
|
842
|
+
if (this.complexitySteered.has(this.sessionId) && this.telegramBridgeSteered.has(this.sessionId)) {
|
|
843
|
+
return false;
|
|
844
|
+
}
|
|
613
845
|
if (this.turnCount > 0) return false;
|
|
846
|
+
// Never wrap meta follow-ups even if somehow first.
|
|
847
|
+
if (
|
|
848
|
+
input.skipSelfRecheck ||
|
|
849
|
+
isSelfRecheckPrompt(input.text) ||
|
|
850
|
+
isTelegramBridgeResultsPrompt(input.text) ||
|
|
851
|
+
isManagerWorkReportPrompt(input.text)
|
|
852
|
+
) {
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
614
855
|
try {
|
|
615
856
|
const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
|
|
616
857
|
const hist = readHistory(path, 8);
|
|
617
858
|
if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
|
|
618
859
|
this.complexitySteered.add(this.sessionId);
|
|
860
|
+
this.telegramBridgeSteered.add(this.sessionId);
|
|
619
861
|
return false;
|
|
620
862
|
}
|
|
621
863
|
} catch {
|
|
@@ -624,9 +866,40 @@ export class SessionRuntime {
|
|
|
624
866
|
return true;
|
|
625
867
|
}
|
|
626
868
|
|
|
869
|
+
/** Memory + topic catalog inject for every real manager user turn. */
|
|
870
|
+
private applyManagerContext(input: PromptInput): PromptInput {
|
|
871
|
+
if (!this.managerMode) return input;
|
|
872
|
+
if (
|
|
873
|
+
isTelegramBridgeResultsPrompt(input.text) ||
|
|
874
|
+
isManagerWorkReportPrompt(input.text) ||
|
|
875
|
+
isSelfRecheckPrompt(input.text)
|
|
876
|
+
) {
|
|
877
|
+
return input;
|
|
878
|
+
}
|
|
879
|
+
if (!this.bridge) return input;
|
|
880
|
+
const userText = stripDirectiveWrappers(input.text) || input.text;
|
|
881
|
+
const block = buildManagerContextBlock({
|
|
882
|
+
userText,
|
|
883
|
+
sessionsDir: this.cfg.sessionsDir,
|
|
884
|
+
store: this.bridge.store,
|
|
885
|
+
forum: this.bridge.forum,
|
|
886
|
+
});
|
|
887
|
+
return {
|
|
888
|
+
...input,
|
|
889
|
+
text: injectManagerContext(input.text, block),
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Stop the current turn for this runtime only.
|
|
895
|
+
* Soft ACP cancel + session-scoped force-complete; never kills the shared
|
|
896
|
+
* agent (that would stop every multiplexed chat and take the bot offline).
|
|
897
|
+
*/
|
|
627
898
|
async cancel(): Promise<boolean> {
|
|
628
899
|
if (!this.busy || !this.sessionId) return false;
|
|
629
900
|
this.cancelled = true;
|
|
901
|
+
// Clear queue of follow-ups for this turn? No — only stop the active turn;
|
|
902
|
+
// queued user messages remain so the user can flush later if they want.
|
|
630
903
|
await this.acp.cancel(this.sessionId);
|
|
631
904
|
return true;
|
|
632
905
|
}
|
|
@@ -647,6 +920,7 @@ export class SessionRuntime {
|
|
|
647
920
|
// Account rotation restarts the process globally. Do not bind a new chat
|
|
648
921
|
// to a candidate account until the owner has finished probing it.
|
|
649
922
|
await this.accountRotator?.waitForIdle();
|
|
923
|
+
await this.applyPreferredAccount();
|
|
650
924
|
if (this.rebindPending && this.sessionId) {
|
|
651
925
|
// The ACP process is frequently mid-restart the first time we re-bind
|
|
652
926
|
// (auto-restart after a crash, or a fresh bot boot), so a single attempt
|
|
@@ -671,6 +945,38 @@ export class SessionRuntime {
|
|
|
671
945
|
if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
|
|
672
946
|
}
|
|
673
947
|
|
|
948
|
+
/** Last preferred account we successfully aligned to (avoids activate thrash). */
|
|
949
|
+
private preferredAccountApplied?: string;
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* If this scope prefers a saved account and the process is on another login,
|
|
953
|
+
* switch before binding the session. Skips when another turn is in flight
|
|
954
|
+
* (account switch restarts the agent) or we already applied this preference.
|
|
955
|
+
*/
|
|
956
|
+
private async applyPreferredAccount(): Promise<void> {
|
|
957
|
+
const preferred = this.preferredAccountId;
|
|
958
|
+
const rotator = this.accountRotator;
|
|
959
|
+
if (!preferred || !rotator) return;
|
|
960
|
+
const st = rotator.state();
|
|
961
|
+
if (st.activeId === preferred) {
|
|
962
|
+
this.preferredAccountApplied = preferred;
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
// User cleared or changed preference — allow one more activate.
|
|
966
|
+
if (this.preferredAccountApplied === preferred) return;
|
|
967
|
+
if (this.acp.hasInflightPrompt()) {
|
|
968
|
+
log.debug(`scope ${this.settingsKey}: skip preferred account (turn in flight)`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
try {
|
|
972
|
+
await rotator.activate(preferred);
|
|
973
|
+
this.preferredAccountApplied = preferred;
|
|
974
|
+
log.info(`scope ${this.settingsKey}: activated preferred account ${preferred.slice(0, 8)}`);
|
|
975
|
+
} catch (e) {
|
|
976
|
+
log.debug(`preferred account activate failed: ${(e as Error).message}`);
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
674
980
|
/** Reload a persisted session, retrying flaky failures with a short backoff.
|
|
675
981
|
* Returns true once loaded, false after the attempts are exhausted. */
|
|
676
982
|
private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
|
|
@@ -711,20 +1017,70 @@ export class SessionRuntime {
|
|
|
711
1017
|
}
|
|
712
1018
|
|
|
713
1019
|
private async runTurn(input: PromptInput): Promise<void> {
|
|
1020
|
+
// Apply before any turn bookkeeping so card previews / logs see the wrapped
|
|
1021
|
+
// text the same way the agent does (also covers flushQueue first messages).
|
|
1022
|
+
try {
|
|
1023
|
+
input = this.applyFirstPromptSteering(input);
|
|
1024
|
+
input = this.applyManagerContext(input);
|
|
1025
|
+
} catch (e) {
|
|
1026
|
+
log.warn(`prompt steering/context failed: ${(e as Error).message}`);
|
|
1027
|
+
}
|
|
1028
|
+
|
|
714
1029
|
this.busy = true;
|
|
715
1030
|
this.cancelled = false;
|
|
716
1031
|
this.turnReplyTo = input.replyTo;
|
|
1032
|
+
this.turnPromptId = input.promptId;
|
|
717
1033
|
this.turnUserText = input.text;
|
|
718
1034
|
this.turnAssistantText = "";
|
|
1035
|
+
this.cardThinking = "";
|
|
1036
|
+
this.turnExpectDone = false;
|
|
1037
|
+
this.turnDonePinged = false;
|
|
719
1038
|
this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
|
|
720
|
-
// Meta turns (recheck,
|
|
721
|
-
|
|
722
|
-
|
|
1039
|
+
// Meta turns (recheck, bridge results, work reports) never arm another recheck.
|
|
1040
|
+
const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
|
|
1041
|
+
const isWorkReport = isManagerWorkReportPrompt(input.text);
|
|
1042
|
+
// Keep the Thinking… bubble across search_memory → results follow-ups so
|
|
1043
|
+
// the user is not left with a deleted placeholder and no reply.
|
|
1044
|
+
if (this.managerStatusMsgId !== undefined && !isBridgeResults) {
|
|
1045
|
+
const orphan = this.managerStatusMsgId;
|
|
1046
|
+
this.managerStatusMsgId = undefined;
|
|
1047
|
+
void this.deleteManagerStatus(orphan);
|
|
1048
|
+
}
|
|
1049
|
+
this.skipSelfRecheck =
|
|
1050
|
+
!!input.skipSelfRecheck ||
|
|
1051
|
+
this.isSelfRecheckTurn ||
|
|
1052
|
+
isBridgeResults ||
|
|
1053
|
+
isWorkReport ||
|
|
1054
|
+
this.managerMode;
|
|
1055
|
+
// Fresh user work resets bridge-chain depth + suggestion anchors.
|
|
1056
|
+
if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
|
|
1057
|
+
this.bridgeResultDepth = 0;
|
|
1058
|
+
}
|
|
1059
|
+
// Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
|
|
723
1060
|
// Strip complexity/reply wrappers so suggestions + recheck see the real ask.
|
|
724
|
-
if (!this.isSelfRecheckTurn) {
|
|
1061
|
+
if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
|
|
725
1062
|
this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
|
|
726
1063
|
this.preRecheckAssistantText = "";
|
|
727
1064
|
this.preRecheckFileOps = new Map();
|
|
1065
|
+
// Card comment: last real user prompt (not self-recheck / empty meta).
|
|
1066
|
+
const preview = input.text.trim()
|
|
1067
|
+
? cleanUserPreview(input.text, COMMENT_MAX)
|
|
1068
|
+
: input.images.length
|
|
1069
|
+
? "Attached image(s)"
|
|
1070
|
+
: "";
|
|
1071
|
+
if (preview) {
|
|
1072
|
+
this.cardUserPrompt = preview;
|
|
1073
|
+
this.setSessionComment(preview);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
// Bind THIS prompt's report-back (manager dispatch). Meta follow-ups
|
|
1077
|
+
// (recheck / bridge results) omit reportBack and keep the prior job until
|
|
1078
|
+
// the queue drains and we report once.
|
|
1079
|
+
if (input.reportBack) {
|
|
1080
|
+
this.pendingReportBack = input.reportBack as ReportBackMeta;
|
|
1081
|
+
}
|
|
1082
|
+
if (this.pendingReportBack && this.sessionId) {
|
|
1083
|
+
bindJobSession(this.pendingReportBack.jobId, this.sessionId);
|
|
728
1084
|
}
|
|
729
1085
|
this.shownToolIds = new Set();
|
|
730
1086
|
this.toolCallCache = new Map();
|
|
@@ -745,13 +1101,64 @@ export class SessionRuntime {
|
|
|
745
1101
|
// A new streamed turn supersedes any transient "follow" watch of this same
|
|
746
1102
|
// session's previous in-flight turn (avoids duplicated output).
|
|
747
1103
|
if (this.watchIsFollow) this.stopWatch();
|
|
748
|
-
|
|
1104
|
+
// Manager (General): stream short chat prose (no tools/progress). Work
|
|
1105
|
+
// reports stay quiet. Bridge-result follow-ups ARE user-facing — that is
|
|
1106
|
+
// usually when the manager answers after search_memory.
|
|
1107
|
+
const managerSilent =
|
|
1108
|
+
this.managerMode && (isWorkReport || this.isSelfRecheckTurn);
|
|
1109
|
+
const live = this.foreground && !managerSilent;
|
|
749
1110
|
const startedAt = Date.now();
|
|
750
1111
|
this.turnStartedAt = startedAt;
|
|
1112
|
+
this.managerNotifyCount = 0;
|
|
1113
|
+
this.managerUserVisible = false;
|
|
1114
|
+
// General: Starting… (from message handler) → Thinking… then stream into it.
|
|
1115
|
+
// Work-report wakes stay fully silent (no bubble).
|
|
1116
|
+
const managerMeta = managerSilent;
|
|
1117
|
+
let thinkingMsgId: number | undefined = input.seedMessageId ?? this.managerStatusMsgId;
|
|
1118
|
+
if (this.managerMode && !managerSilent && this.turnReplyTo !== undefined) {
|
|
1119
|
+
if (thinkingMsgId !== undefined) {
|
|
1120
|
+
await this.editManagerStatus(thinkingMsgId, "Thinking\u2026");
|
|
1121
|
+
} else {
|
|
1122
|
+
thinkingMsgId = await this.postManagerStatus(this.turnReplyTo, "Thinking\u2026");
|
|
1123
|
+
}
|
|
1124
|
+
this.managerStatusMsgId = thinkingMsgId;
|
|
1125
|
+
} else if (this.managerMode && managerSilent && thinkingMsgId !== undefined && !isBridgeResults) {
|
|
1126
|
+
// Drop Starting… leftover on silent work-report / recheck turns.
|
|
1127
|
+
await this.deleteManagerStatus(thinkingMsgId);
|
|
1128
|
+
thinkingMsgId = undefined;
|
|
1129
|
+
this.managerStatusMsgId = undefined;
|
|
1130
|
+
}
|
|
751
1131
|
this.streamer = live
|
|
752
|
-
? new ResponseStreamer(
|
|
1132
|
+
? new ResponseStreamer(
|
|
1133
|
+
this.api,
|
|
1134
|
+
this.chatId,
|
|
1135
|
+
this.cfg.streamThrottleMs,
|
|
1136
|
+
this.turnReplyTo,
|
|
1137
|
+
this.hashtags(),
|
|
1138
|
+
this.managerMode ? undefined : (pct) => this.setProgress(pct),
|
|
1139
|
+
this.managerMode ? false : this.cfg.progressFallback,
|
|
1140
|
+
startedAt,
|
|
1141
|
+
this.messageThreadId,
|
|
1142
|
+
this.managerMode
|
|
1143
|
+
? {
|
|
1144
|
+
proseOnly: true,
|
|
1145
|
+
showProgressBar: false,
|
|
1146
|
+
seedMessageId: thinkingMsgId,
|
|
1147
|
+
}
|
|
1148
|
+
: undefined,
|
|
1149
|
+
)
|
|
753
1150
|
: undefined;
|
|
754
|
-
|
|
1151
|
+
// Bind user message (+ status bubble) → session for reply routing.
|
|
1152
|
+
if (this.managerMode && this.sessionId) {
|
|
1153
|
+
if (this.turnReplyTo !== undefined) {
|
|
1154
|
+
this.onTelegramMessageBound?.(this.turnReplyTo, this.sessionId);
|
|
1155
|
+
}
|
|
1156
|
+
if (thinkingMsgId !== undefined) {
|
|
1157
|
+
this.onTelegramMessageBound?.(thinkingMsgId, this.sessionId);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
// Typing indicator for user-facing manager turns and live project streams.
|
|
1161
|
+
if (live || (this.managerMode && !managerMeta)) this.typing.start();
|
|
755
1162
|
this.activity(true);
|
|
756
1163
|
this.changed();
|
|
757
1164
|
this.imageScanText = "";
|
|
@@ -760,8 +1167,13 @@ export class SessionRuntime {
|
|
|
760
1167
|
const content = buildContentBlocks(input, {
|
|
761
1168
|
reasoning: reasoningDirective(this.reasoning),
|
|
762
1169
|
priming: this.primingContext,
|
|
763
|
-
imageOutput:
|
|
764
|
-
|
|
1170
|
+
imageOutput:
|
|
1171
|
+
!this.managerMode && this.cfg.sendAgentImages
|
|
1172
|
+
? IMAGE_OUTPUT_DIRECTIVE
|
|
1173
|
+
: undefined,
|
|
1174
|
+
// Manager chat: no progress spam; project topics keep the usual directive.
|
|
1175
|
+
progress:
|
|
1176
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
765
1177
|
});
|
|
766
1178
|
this.primingContext = undefined;
|
|
767
1179
|
|
|
@@ -786,6 +1198,15 @@ export class SessionRuntime {
|
|
|
786
1198
|
if (final.result && !this.cancelled) this.streamer?.completeFallback();
|
|
787
1199
|
if (this.streamer) await this.streamer.finalize();
|
|
788
1200
|
if (this.foreground) await this.sendTurnImages();
|
|
1201
|
+
|
|
1202
|
+
// Telegram bridge actions (JSON fences in the agent reply). Process on
|
|
1203
|
+
// normal turns AND bridge-results follow-ups so multi-step bot_command /
|
|
1204
|
+
// search chains work; depth cap prevents infinite loops.
|
|
1205
|
+
let queuedBridgeResults = false;
|
|
1206
|
+
if (final.result && !this.cancelled) {
|
|
1207
|
+
queuedBridgeResults = await this.processTelegramBridgeActions();
|
|
1208
|
+
}
|
|
1209
|
+
|
|
789
1210
|
// Always build the completion (records `lastCompletion` so switching back
|
|
790
1211
|
// to this session can replay its Done + summary). Only PING the chat for
|
|
791
1212
|
// the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
|
|
@@ -798,49 +1219,40 @@ export class SessionRuntime {
|
|
|
798
1219
|
this.turnCount++;
|
|
799
1220
|
// Persist real per-account usage (turns + reported credits) for /accounts and /usage.
|
|
800
1221
|
this.recordAccountUsage();
|
|
801
|
-
// Card comment:
|
|
802
|
-
this.
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
stopReason: final.result.stopReason,
|
|
808
|
-
}),
|
|
809
|
-
);
|
|
810
|
-
this.setLiveStep(undefined);
|
|
1222
|
+
// Card comment: last user prompt (thinking cleared when idle).
|
|
1223
|
+
this.persistCardUserPrompt();
|
|
1224
|
+
this.cardThinking = "";
|
|
1225
|
+
// Keep live step while bridge/sibling-bot results are still queued —
|
|
1226
|
+
// clearing here made "Waiting for bot" vanish before the interim notify.
|
|
1227
|
+
if (!queuedBridgeResults) this.setLiveStep(undefined);
|
|
811
1228
|
} else if (this.cancelled) {
|
|
812
|
-
this.
|
|
813
|
-
|
|
814
|
-
userText: this.turnUserText,
|
|
815
|
-
assistantText: this.turnAssistantText,
|
|
816
|
-
fileOps: this.fileOps,
|
|
817
|
-
cancelled: true,
|
|
818
|
-
}),
|
|
819
|
-
);
|
|
1229
|
+
this.persistCardUserPrompt();
|
|
1230
|
+
this.cardThinking = "";
|
|
820
1231
|
this.setLiveStep(undefined);
|
|
821
1232
|
} 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
|
-
);
|
|
1233
|
+
this.persistCardUserPrompt();
|
|
1234
|
+
this.cardThinking = "";
|
|
830
1235
|
this.setLiveStep(undefined);
|
|
831
1236
|
}
|
|
832
1237
|
if (final.result || this.cancelled) {
|
|
833
|
-
|
|
834
|
-
|
|
1238
|
+
// Bridge results in the queue mean "not Done yet" — never treat as a
|
|
1239
|
+
// completion ping even in the foreground. Do not post bridge status
|
|
1240
|
+
// spam to the chat (live step / status panel only).
|
|
1241
|
+
const pingDone =
|
|
1242
|
+
canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
|
|
1243
|
+
// Manager uses notify/finishManagerUserFacing — never arm Done safety-net spam.
|
|
1244
|
+
this.turnExpectDone = pingDone && !this.managerMode;
|
|
835
1245
|
|
|
836
1246
|
// One-shot self-recheck: only after a real *user* turn (not meta/auto),
|
|
837
1247
|
// with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
|
|
838
1248
|
// Also skipped when no files were modified, or when a quiet AI decision
|
|
839
1249
|
// refuses (simple tasks, pure build, nothing worth re-verifying).
|
|
1250
|
+
// Delay recheck/Done when bridge results are queued (like self-recheck).
|
|
840
1251
|
const wantSelfRecheck =
|
|
841
1252
|
!!final.result &&
|
|
842
1253
|
!this.cancelled &&
|
|
843
1254
|
!hasQueued &&
|
|
1255
|
+
!queuedBridgeResults &&
|
|
844
1256
|
this.cfg.selfRecheckEnabled &&
|
|
845
1257
|
!this.skipSelfRecheck &&
|
|
846
1258
|
!this.isSelfRecheckTurn;
|
|
@@ -853,6 +1265,13 @@ export class SessionRuntime {
|
|
|
853
1265
|
this.preRecheckFileOps = cloneFileOps(this.fileOps);
|
|
854
1266
|
this.setLiveStep("Deciding if self-recheck is needed\u2026");
|
|
855
1267
|
this.changed();
|
|
1268
|
+
// Visible chat status so stream-complete is not mistaken for a silent exit.
|
|
1269
|
+
if (pingDone) {
|
|
1270
|
+
await this.notify(
|
|
1271
|
+
"\u{1F50D} Checking if a quality pass is needed\u2026",
|
|
1272
|
+
{ loud: true, replyTo: this.turnReplyTo },
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
856
1275
|
const recheck = await this.maybePlanSelfRecheck();
|
|
857
1276
|
this.setLiveStep(undefined);
|
|
858
1277
|
// User may cancel during the quiet decision call — first turn still
|
|
@@ -862,9 +1281,13 @@ export class SessionRuntime {
|
|
|
862
1281
|
this.preRecheckAssistantText = "";
|
|
863
1282
|
} else if (recheck) {
|
|
864
1283
|
queuedRecheck = true;
|
|
1284
|
+
this.turnExpectDone = false; // final Done comes after the recheck turn
|
|
865
1285
|
// Front of queue; mark skip so the recheck turn never re-arms itself.
|
|
866
1286
|
this.queue.unshift(
|
|
867
|
-
textPrompt(recheck, this.turnReplyTo, undefined, {
|
|
1287
|
+
textPrompt(recheck, this.turnReplyTo, undefined, {
|
|
1288
|
+
skipSelfRecheck: true,
|
|
1289
|
+
promptId: this.turnPromptId,
|
|
1290
|
+
}),
|
|
868
1291
|
);
|
|
869
1292
|
this.changed();
|
|
870
1293
|
if (pingDone) {
|
|
@@ -883,123 +1306,254 @@ export class SessionRuntime {
|
|
|
883
1306
|
}
|
|
884
1307
|
}
|
|
885
1308
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
1309
|
+
// Manager: keep Thinking… while bridge results are chaining so the
|
|
1310
|
+
// follow-up turn can stream the real answer into the same bubble.
|
|
1311
|
+
if (this.managerMode && queuedBridgeResults && this.managerStatusMsgId !== undefined) {
|
|
1312
|
+
void this.editManagerStatus(this.managerStatusMsgId, "Thinking\u2026");
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
if (!queuedRecheck && !queuedBridgeResults) {
|
|
1316
|
+
// Manager (General): quiet-by-default completion — no Done spam.
|
|
1317
|
+
// User-facing only via notify (already sent) or one fallback reply.
|
|
1318
|
+
if (this.managerMode) {
|
|
1319
|
+
await this.finishManagerUserFacing(managerMeta, final.result?.stopReason, startedAt);
|
|
1320
|
+
this.turnDonePinged = true;
|
|
1321
|
+
// Suggestions only when something was actually shown to the user.
|
|
1322
|
+
if (
|
|
1323
|
+
final.result &&
|
|
1324
|
+
!this.cancelled &&
|
|
1325
|
+
!hasQueued &&
|
|
1326
|
+
this.managerUserVisible &&
|
|
1327
|
+
!managerMeta
|
|
1328
|
+
) {
|
|
1329
|
+
try {
|
|
1330
|
+
const baseForSug =
|
|
1331
|
+
cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) || "Done";
|
|
1332
|
+
await this.collectAndApplySuggestions(baseForSug, undefined, {
|
|
1333
|
+
autoQueue: true,
|
|
1334
|
+
});
|
|
1335
|
+
} catch (e) {
|
|
1336
|
+
log.debug(`manager suggestions failed: ${(e as Error).message}`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
} else {
|
|
1340
|
+
// Build Done text *now* (after quiet decision) so a cancel during the
|
|
1341
|
+
// recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
|
|
891
1342
|
let doneText = this.isSelfRecheckTurn
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1343
|
+
? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
|
|
1344
|
+
: this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
|
|
1345
|
+
// 1) Always send Done FIRST — never block the completion ping on the
|
|
1346
|
+
// quiet suggestions prompt (which can hang and look like "no Done").
|
|
1347
|
+
let doneMsgId: number | undefined;
|
|
1348
|
+
const shouldPingDone = pingDone;
|
|
1349
|
+
if (shouldPingDone && doneText.trim()) {
|
|
1350
|
+
doneMsgId = await this.notify(doneText, {
|
|
1351
|
+
loud: true,
|
|
1352
|
+
replyTo: this.turnReplyTo,
|
|
1353
|
+
replyMarkup: switchKb,
|
|
899
1354
|
});
|
|
900
|
-
|
|
901
|
-
|
|
1355
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1356
|
+
}
|
|
1357
|
+
// 2) Suggestions: project topics keep Done-edit UX.
|
|
1358
|
+
if (final.result && !this.cancelled && !hasQueued) {
|
|
1359
|
+
try {
|
|
1360
|
+
const baseForSug = doneText;
|
|
1361
|
+
const sug = await this.collectAndApplySuggestions(
|
|
1362
|
+
baseForSug,
|
|
1363
|
+
switchKb,
|
|
1364
|
+
{
|
|
1365
|
+
autoQueue: this.foreground,
|
|
1366
|
+
},
|
|
1367
|
+
);
|
|
1368
|
+
if (shouldPingDone && sug.text !== doneText) {
|
|
1369
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1370
|
+
}
|
|
1371
|
+
} catch (e) {
|
|
1372
|
+
log.debug(`suggestions after Done failed: ${(e as Error).message}`);
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
902
1375
|
}
|
|
903
|
-
if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
|
|
904
1376
|
// Clear frozen first-turn ops after final Done (recheck path done).
|
|
905
1377
|
if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
|
|
1378
|
+
|
|
1379
|
+
// Child work dispatched from General → wake manager with a report.
|
|
1380
|
+
// (Waits only for same-job meta follow-ups; see maybeReportBackToManager.)
|
|
1381
|
+
await this.maybeReportBackToManager({
|
|
1382
|
+
ok: !!final.result && !this.cancelled,
|
|
1383
|
+
cancelled: this.cancelled,
|
|
1384
|
+
stopReason: final.result?.stopReason,
|
|
1385
|
+
error: undefined,
|
|
1386
|
+
});
|
|
906
1387
|
}
|
|
1388
|
+
// queuedBridgeResults: stay quiet in chat — agent gets results via queue.
|
|
907
1389
|
} else if (final.error) {
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
|
|
1390
|
+
// Manager: one short important message (or edit Thinking…); never Done spam.
|
|
1391
|
+
if (this.managerMode) {
|
|
1392
|
+
await this.finishManagerError(final.error.message, startedAt);
|
|
1393
|
+
this.turnDonePinged = true;
|
|
1394
|
+
await this.maybeReportBackToManager({
|
|
1395
|
+
ok: false,
|
|
1396
|
+
cancelled: false,
|
|
1397
|
+
error: final.error.message,
|
|
1398
|
+
});
|
|
1399
|
+
} else if (this.isSelfRecheckTurn && !hasQueued) {
|
|
1400
|
+
// If the self-recheck pass itself failed, still surface Done for the
|
|
1401
|
+
// original work (split files + suggestions) so the user is not stuck.
|
|
911
1402
|
const switchKb = this.switchKeyboard();
|
|
912
1403
|
const pingDone = canPing && (this.foreground || !hasQueued);
|
|
1404
|
+
this.turnExpectDone = pingDone;
|
|
913
1405
|
let doneText =
|
|
914
1406
|
this.completionMessageSplit(undefined, startedAt, streamedOutput) +
|
|
915
1407
|
`\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1408
|
+
let doneMsgId: number | undefined;
|
|
1409
|
+
if (pingDone) {
|
|
1410
|
+
doneMsgId = await this.notify(doneText, {
|
|
1411
|
+
loud: true,
|
|
1412
|
+
replyTo: this.turnReplyTo,
|
|
1413
|
+
replyMarkup: switchKb,
|
|
919
1414
|
});
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1415
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1416
|
+
}
|
|
1417
|
+
if (!this.cancelled) {
|
|
1418
|
+
try {
|
|
1419
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
1420
|
+
autoQueue: this.foreground,
|
|
926
1421
|
});
|
|
1422
|
+
if (pingDone && sug.text !== doneText) {
|
|
1423
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1424
|
+
}
|
|
1425
|
+
} catch (e) {
|
|
1426
|
+
log.debug(`suggestions after recheck-fail Done failed: ${(e as Error).message}`);
|
|
927
1427
|
}
|
|
928
|
-
} else if (pingDone) {
|
|
929
|
-
await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
|
|
930
1428
|
}
|
|
931
1429
|
this.preRecheckFileOps = new Map();
|
|
1430
|
+
// Self-recheck failed after real work — still report manager job if any.
|
|
1431
|
+
await this.maybeReportBackToManager({
|
|
1432
|
+
ok: true,
|
|
1433
|
+
cancelled: this.cancelled,
|
|
1434
|
+
stopReason: "self_recheck_failed",
|
|
1435
|
+
error: final.error.message,
|
|
1436
|
+
});
|
|
932
1437
|
} else {
|
|
933
1438
|
const transient = isTransientError(final.error);
|
|
934
1439
|
const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
|
|
935
|
-
|
|
1440
|
+
this.turnExpectDone = canPing;
|
|
1441
|
+
if (canPing) {
|
|
1442
|
+
const id = await this.notify(liveMsg, {
|
|
1443
|
+
loud: true,
|
|
1444
|
+
replyTo: this.turnReplyTo,
|
|
1445
|
+
replyMarkup: switchKb,
|
|
1446
|
+
});
|
|
1447
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1448
|
+
}
|
|
1449
|
+
await this.maybeReportBackToManager({
|
|
1450
|
+
ok: false,
|
|
1451
|
+
cancelled: false,
|
|
1452
|
+
error: final.error.message,
|
|
1453
|
+
});
|
|
936
1454
|
}
|
|
937
1455
|
}
|
|
938
1456
|
} catch (err) {
|
|
939
1457
|
// Unexpected failure outside the prompt path (e.g. while finalizing).
|
|
940
1458
|
await this.streamer?.finalize().catch(() => {});
|
|
941
1459
|
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
|
-
);
|
|
1460
|
+
this.persistCardUserPrompt();
|
|
1461
|
+
this.cardThinking = "";
|
|
950
1462
|
this.setLiveStep(undefined);
|
|
951
1463
|
// If the self-recheck pass itself blew up, still surface Done for the
|
|
952
1464
|
// original work (split files + suggestions) so the user is not stuck.
|
|
953
1465
|
if (this.isSelfRecheckTurn && this.queue.length === 0) {
|
|
954
1466
|
const switchKb = this.switchKeyboard();
|
|
955
1467
|
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
1468
|
+
this.turnExpectDone = canPing;
|
|
956
1469
|
let doneText =
|
|
957
1470
|
this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
|
|
958
1471
|
`\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
|
|
959
1472
|
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, {
|
|
1473
|
+
let doneMsgId: number | undefined;
|
|
1474
|
+
if (canPing) {
|
|
1475
|
+
doneMsgId = await this.notify(doneText, {
|
|
974
1476
|
loud: true,
|
|
975
1477
|
replyTo: this.turnReplyTo,
|
|
976
1478
|
replyMarkup: switchKb,
|
|
977
1479
|
});
|
|
1480
|
+
if (doneMsgId !== undefined) this.turnDonePinged = true;
|
|
1481
|
+
}
|
|
1482
|
+
if (!this.cancelled) {
|
|
1483
|
+
try {
|
|
1484
|
+
const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
|
|
1485
|
+
autoQueue: this.foreground,
|
|
1486
|
+
});
|
|
1487
|
+
if (canPing && sug.text !== doneText) {
|
|
1488
|
+
await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
|
|
1489
|
+
}
|
|
1490
|
+
} catch (e2) {
|
|
1491
|
+
log.debug(`suggestions after recheck catch failed: ${(e2 as Error).message}`);
|
|
1492
|
+
}
|
|
978
1493
|
}
|
|
979
1494
|
} catch (e2) {
|
|
980
1495
|
log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
|
|
981
|
-
if (canPing) {
|
|
982
|
-
await this.notify(doneText, {
|
|
1496
|
+
if (canPing && !this.turnDonePinged) {
|
|
1497
|
+
const id = await this.notify(doneText, {
|
|
983
1498
|
loud: true,
|
|
984
1499
|
replyTo: this.turnReplyTo,
|
|
985
1500
|
replyMarkup: switchKb,
|
|
986
|
-
})
|
|
1501
|
+
});
|
|
1502
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
987
1503
|
}
|
|
988
1504
|
}
|
|
989
1505
|
this.preRecheckFileOps = new Map();
|
|
1506
|
+
} else if (this.managerMode) {
|
|
1507
|
+
await this.finishManagerError(errMsg, startedAt);
|
|
1508
|
+
this.turnDonePinged = true;
|
|
1509
|
+
await this.maybeReportBackToManager({
|
|
1510
|
+
ok: false,
|
|
1511
|
+
cancelled: this.cancelled,
|
|
1512
|
+
error: errMsg,
|
|
1513
|
+
});
|
|
990
1514
|
} else {
|
|
991
1515
|
const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
|
|
992
1516
|
this.lastCompletion = msg;
|
|
993
|
-
|
|
1517
|
+
const canPing = this.foreground || this.cfg.notifyOtherSessions;
|
|
1518
|
+
this.turnExpectDone = canPing;
|
|
1519
|
+
if (canPing) {
|
|
994
1520
|
const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
|
|
995
|
-
await this.notify(`${from}${msg}`, {
|
|
1521
|
+
const id = await this.notify(`${from}${msg}`, {
|
|
996
1522
|
loud: true,
|
|
997
1523
|
replyTo: this.turnReplyTo,
|
|
998
1524
|
replyMarkup: this.switchKeyboard(),
|
|
999
1525
|
});
|
|
1526
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1000
1527
|
}
|
|
1528
|
+
await this.maybeReportBackToManager({
|
|
1529
|
+
ok: false,
|
|
1530
|
+
cancelled: this.cancelled,
|
|
1531
|
+
error: errMsg,
|
|
1532
|
+
});
|
|
1001
1533
|
}
|
|
1002
1534
|
} finally {
|
|
1535
|
+
// Safety net: turn completed with an expected Done ping that never landed
|
|
1536
|
+
// (notify failed, hung path, etc.). Never block queue flush on this.
|
|
1537
|
+
// Manager never uses this path (turnExpectDone is false in manager mode).
|
|
1538
|
+
if (this.turnExpectDone && !this.turnDonePinged && !this.managerMode) {
|
|
1539
|
+
const fallback =
|
|
1540
|
+
this.lastCompletion?.trim() ||
|
|
1541
|
+
`\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
|
|
1542
|
+
const short =
|
|
1543
|
+
fallback.length > 3500 ? fallback.slice(0, 3499) + "\u2026" : fallback;
|
|
1544
|
+
try {
|
|
1545
|
+
const id = await this.notify(short, {
|
|
1546
|
+
loud: true,
|
|
1547
|
+
replyTo: this.turnReplyTo,
|
|
1548
|
+
replyMarkup: this.switchKeyboard(),
|
|
1549
|
+
});
|
|
1550
|
+
if (id !== undefined) this.turnDonePinged = true;
|
|
1551
|
+
else log.warn(`chat ${this.chatId}: Done safety-net notify failed`);
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
log.warn(`chat ${this.chatId}: Done safety-net error: ${(e as Error).message}`);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
this.turnExpectDone = false;
|
|
1003
1557
|
this.typing.stop();
|
|
1004
1558
|
this.streamer = undefined;
|
|
1005
1559
|
this.capturingQuiet = false;
|
|
@@ -1013,7 +1567,8 @@ export class SessionRuntime {
|
|
|
1013
1567
|
// messages. The finished streamed bubble keeps its own (frozen) bar.
|
|
1014
1568
|
this.progress = undefined;
|
|
1015
1569
|
this.planEntries = undefined;
|
|
1016
|
-
//
|
|
1570
|
+
// Idle cards show last user prompt only (clear live step / thinking).
|
|
1571
|
+
this.cardThinking = "";
|
|
1017
1572
|
if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
|
|
1018
1573
|
this.changed();
|
|
1019
1574
|
}
|
|
@@ -1029,6 +1584,122 @@ export class SessionRuntime {
|
|
|
1029
1584
|
}
|
|
1030
1585
|
}
|
|
1031
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* Parse telegram JSON actions from the assistant reply, execute them, notify
|
|
1589
|
+
* the user, and queue a results follow-up for the agent when useful.
|
|
1590
|
+
* Returns true when a results prompt was queued (delay Done/recheck).
|
|
1591
|
+
*/
|
|
1592
|
+
private async processTelegramBridgeActions(): Promise<boolean> {
|
|
1593
|
+
const { actions, cleaned } = extractTelegramActions(this.turnAssistantText);
|
|
1594
|
+
if (cleaned !== this.turnAssistantText) {
|
|
1595
|
+
this.turnAssistantText = cleaned;
|
|
1596
|
+
}
|
|
1597
|
+
if (actions.length === 0) return false;
|
|
1598
|
+
if (!this.bridge) {
|
|
1599
|
+
log.warn(`chat ${this.chatId}: telegram actions present but bridge not wired`);
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
const botCmds = actions.filter((a) => a.action === "bot_command");
|
|
1604
|
+
// bot_command means Grok ended its turn early to wait on a sibling bot —
|
|
1605
|
+
// this is NOT a Done. Keep busy; status panel live-step only (no chat spam).
|
|
1606
|
+
if (botCmds.length > 0) {
|
|
1607
|
+
const labels = botCmds
|
|
1608
|
+
.map((a) =>
|
|
1609
|
+
a.action === "bot_command" ? `@${a.bot} /${a.command}` : "",
|
|
1610
|
+
)
|
|
1611
|
+
.filter(Boolean)
|
|
1612
|
+
.join(", ");
|
|
1613
|
+
this.setLiveStep(`Waiting for sibling bot: ${labels}`);
|
|
1614
|
+
} else {
|
|
1615
|
+
this.setLiveStep("Running Telegram bridge actions\u2026");
|
|
1616
|
+
}
|
|
1617
|
+
this.changed();
|
|
1618
|
+
log.info(`chat ${this.chatId}: executing ${actions.length} telegram bridge action(s)`);
|
|
1619
|
+
|
|
1620
|
+
const results = await executeTelegramActions(actions, {
|
|
1621
|
+
api: this.api,
|
|
1622
|
+
cfg: this.cfg,
|
|
1623
|
+
chatId: this.chatId,
|
|
1624
|
+
messageThreadId: this.messageThreadId,
|
|
1625
|
+
replyToMessageId: this.turnReplyTo,
|
|
1626
|
+
forum: this.bridge.forum,
|
|
1627
|
+
store: this.bridge.store,
|
|
1628
|
+
bots: this.bridge.bots,
|
|
1629
|
+
submitTopicPrompt: this.bridge.submitTopicPrompt,
|
|
1630
|
+
managerMode: this.managerMode,
|
|
1631
|
+
managerUserAskPreview: this.suggestionUserText || cleanUserPreview(this.turnUserText, 400),
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
// Count successful notify actions. Do not delete the live stream bubble
|
|
1635
|
+
// (same id as Thinking…) — that would wipe the user's visible reply.
|
|
1636
|
+
for (const r of results) {
|
|
1637
|
+
if (r.action === "notify" && r.ok) {
|
|
1638
|
+
this.managerNotifyCount++;
|
|
1639
|
+
this.managerUserVisible = true;
|
|
1640
|
+
const streamLive = this.streamer?.liveMessageId;
|
|
1641
|
+
if (
|
|
1642
|
+
this.managerStatusMsgId !== undefined &&
|
|
1643
|
+
this.managerStatusMsgId !== streamLive &&
|
|
1644
|
+
!this.streamer?.hasOutput
|
|
1645
|
+
) {
|
|
1646
|
+
const sid = this.managerStatusMsgId;
|
|
1647
|
+
this.managerStatusMsgId = undefined;
|
|
1648
|
+
void this.deleteManagerStatus(sid);
|
|
1649
|
+
}
|
|
1650
|
+
const mid = (r.data as { messageId?: number } | undefined)?.messageId;
|
|
1651
|
+
if (mid !== undefined && this.sessionId) {
|
|
1652
|
+
this.onTelegramMessageBound?.(mid, this.sessionId);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
// Only announce durable side-effects in chat (topic create/bind/cross-prompt).
|
|
1658
|
+
// Manager mode stays quiet — use notify for user text; no auto notes.
|
|
1659
|
+
// search_memory / list_* / bot wait status stay silent — results go to the agent.
|
|
1660
|
+
const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
|
|
1661
|
+
const notes = results
|
|
1662
|
+
.filter((r) => durableActions.has(r.action) && r.userNote?.trim())
|
|
1663
|
+
.map((r) => r.userNote!)
|
|
1664
|
+
.filter(Boolean);
|
|
1665
|
+
if (notes.length > 0 && this.foreground && !this.managerMode) {
|
|
1666
|
+
await this.notify(notes.join("\n"), {
|
|
1667
|
+
loud: true,
|
|
1668
|
+
replyTo: this.turnReplyTo,
|
|
1669
|
+
});
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
// Cap chained result turns so a model that re-emits list_bots forever cannot
|
|
1673
|
+
// block Done. Side-effects already ran; user notes were sent above.
|
|
1674
|
+
// Still queue once when we have bot_command errors so the agent can recover.
|
|
1675
|
+
if (this.bridgeResultDepth >= SessionRuntime.BRIDGE_CHAIN_MAX) {
|
|
1676
|
+
log.warn(
|
|
1677
|
+
`chat ${this.chatId}: telegram bridge chain depth ${this.bridgeResultDepth} — not re-queuing results`,
|
|
1678
|
+
);
|
|
1679
|
+
return false;
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// Feed results back so the agent can use search hits / bot replies / errors.
|
|
1683
|
+
const prompt = buildTelegramBridgeResultsPrompt(
|
|
1684
|
+
results.map((r) => ({
|
|
1685
|
+
action: r.action,
|
|
1686
|
+
ok: r.ok,
|
|
1687
|
+
data: r.data,
|
|
1688
|
+
error: r.error,
|
|
1689
|
+
})),
|
|
1690
|
+
);
|
|
1691
|
+
this.bridgeResultDepth++;
|
|
1692
|
+
this.queue.unshift(
|
|
1693
|
+
textPrompt(prompt, this.turnReplyTo, undefined, {
|
|
1694
|
+
skipSelfRecheck: true,
|
|
1695
|
+
promptId: this.turnPromptId,
|
|
1696
|
+
}),
|
|
1697
|
+
);
|
|
1698
|
+
this.setLiveStep("Feeding sibling-bot / bridge results to the agent\u2026");
|
|
1699
|
+
this.changed();
|
|
1700
|
+
return true;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1032
1703
|
/**
|
|
1033
1704
|
* Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
|
|
1034
1705
|
* for switch-replay, and optionally queue auto-approved items as **one**
|
|
@@ -1038,7 +1709,7 @@ export class SessionRuntime {
|
|
|
1038
1709
|
doneText: string,
|
|
1039
1710
|
switchKb: InlineKeyboard | undefined,
|
|
1040
1711
|
opts?: { autoQueue?: boolean },
|
|
1041
|
-
): Promise<{ text: string; markup?: InlineKeyboard }> {
|
|
1712
|
+
): Promise<{ text: string; markup?: InlineKeyboard; suggestions?: Suggestion[] }> {
|
|
1042
1713
|
if (!this.cfg.suggestionsEnabled || !this.sessionId) {
|
|
1043
1714
|
return { text: doneText, markup: switchKb };
|
|
1044
1715
|
}
|
|
@@ -1048,6 +1719,8 @@ export class SessionRuntime {
|
|
|
1048
1719
|
} catch (e) {
|
|
1049
1720
|
log.debug(`suggestions fetch failed: ${(e as Error).message}`);
|
|
1050
1721
|
}
|
|
1722
|
+
// Manager: keep 1–4 short follow-ups only.
|
|
1723
|
+
if (this.managerMode) suggestions = suggestions.slice(0, 4);
|
|
1051
1724
|
if (suggestions.length === 0) return { text: doneText, markup: switchKb };
|
|
1052
1725
|
|
|
1053
1726
|
const batchId = ++this.suggestionBatchSeq;
|
|
@@ -1065,18 +1738,26 @@ export class SessionRuntime {
|
|
|
1065
1738
|
let banner: string;
|
|
1066
1739
|
if (auto.length > 0) {
|
|
1067
1740
|
const batched = formatBatchedSuggestionsPrompt(auto);
|
|
1068
|
-
|
|
1741
|
+
// Hard, visible auto-approve block (especially for General chat).
|
|
1742
|
+
const lines = auto.map((s) => `\u2022 ${s.text}`);
|
|
1069
1743
|
const autoBlock =
|
|
1070
|
-
`\n\n\
|
|
1071
|
-
|
|
1744
|
+
`\n\n\u2705 Auto Approved:\n${lines.join("\n")}` +
|
|
1745
|
+
(this.managerMode ? "" : `\n(need \u2265 ${thr}%)`);
|
|
1072
1746
|
text += autoBlock;
|
|
1073
|
-
banner = `\
|
|
1747
|
+
banner = `\u2705 Auto Approved:\n${lines.join("\n")}`;
|
|
1074
1748
|
// Single queue entry — agent executes 1) 2) 3) in one turn.
|
|
1075
1749
|
// skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
|
|
1076
|
-
this.queue.push(
|
|
1750
|
+
this.queue.push(
|
|
1751
|
+
textPrompt(batched, this.turnReplyTo, undefined, {
|
|
1752
|
+
skipSelfRecheck: true,
|
|
1753
|
+
promptId: this.turnPromptId,
|
|
1754
|
+
}),
|
|
1755
|
+
);
|
|
1077
1756
|
this.changed();
|
|
1078
1757
|
} else {
|
|
1079
|
-
text +=
|
|
1758
|
+
text += this.managerMode
|
|
1759
|
+
? "\n\nTap a suggestion to continue:"
|
|
1760
|
+
: "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1080
1761
|
banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
|
|
1081
1762
|
}
|
|
1082
1763
|
|
|
@@ -1084,8 +1765,168 @@ export class SessionRuntime {
|
|
|
1084
1765
|
// the same keyboard). Cleared when a new turn starts.
|
|
1085
1766
|
this.pendingSuggestions = { batchId, suggestions, banner };
|
|
1086
1767
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1768
|
+
// Keyboard: show remaining (non-auto) suggestions; if all auto, no buttons.
|
|
1769
|
+
const remaining = suggestions.filter((s) => !auto.some((a) => a.text === s.text));
|
|
1770
|
+
const markup =
|
|
1771
|
+
remaining.length > 0
|
|
1772
|
+
? suggestionsKeyboard(batchId, remaining, switchKb)
|
|
1773
|
+
: switchKb;
|
|
1774
|
+
return { text, markup, suggestions };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
/** Post General status bubble (Starting… / Thinking…) — streamer edits it later. */
|
|
1778
|
+
private async postManagerStatus(
|
|
1779
|
+
replyTo: number,
|
|
1780
|
+
text: string,
|
|
1781
|
+
): Promise<number | undefined> {
|
|
1782
|
+
try {
|
|
1783
|
+
const extra: Record<string, unknown> = {
|
|
1784
|
+
disable_notification: true,
|
|
1785
|
+
...outboundThreadExtra(this.messageThreadId),
|
|
1786
|
+
reply_parameters: {
|
|
1787
|
+
message_id: replyTo,
|
|
1788
|
+
allow_sending_without_reply: true,
|
|
1789
|
+
},
|
|
1790
|
+
};
|
|
1791
|
+
const msg = await this.api.sendMessage(this.chatId, text, extra);
|
|
1792
|
+
return msg.message_id;
|
|
1793
|
+
} catch (e) {
|
|
1794
|
+
log.debug(`manager status "${text}" failed: ${(e as Error).message}`);
|
|
1795
|
+
return undefined;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
private async editManagerStatus(messageId: number, text: string): Promise<void> {
|
|
1800
|
+
try {
|
|
1801
|
+
await this.api.editMessageText(this.chatId, messageId, text);
|
|
1802
|
+
} catch (e) {
|
|
1803
|
+
log.debug(`manager status edit failed: ${(e as Error).message}`);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
private async deleteManagerStatus(messageId: number): Promise<void> {
|
|
1808
|
+
try {
|
|
1809
|
+
await this.api.deleteMessage(this.chatId, messageId);
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
log.debug(`manager status delete failed: ${(e as Error).message}`);
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/** Surface a short important error in General; clear Thinking… bubble. */
|
|
1816
|
+
private async finishManagerError(errorMessage: string, startedAt: number): Promise<void> {
|
|
1817
|
+
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
1818
|
+
const short =
|
|
1819
|
+
errorMessage.length > 280 ? errorMessage.slice(0, 277) + "\u2026" : errorMessage;
|
|
1820
|
+
const text = `\u274C ${short} \u00B7 ${elapsed}`;
|
|
1821
|
+
this.lastCompletion = text;
|
|
1822
|
+
if (this.managerStatusMsgId !== undefined) {
|
|
1823
|
+
await this.editManagerStatus(this.managerStatusMsgId, text);
|
|
1824
|
+
this.managerUserVisible = true;
|
|
1825
|
+
if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
|
|
1826
|
+
this.managerStatusMsgId = undefined;
|
|
1827
|
+
return;
|
|
1828
|
+
}
|
|
1829
|
+
if (this.turnReplyTo !== undefined) {
|
|
1830
|
+
const id = await this.notify(text, { loud: true, replyTo: this.turnReplyTo });
|
|
1831
|
+
if (id !== undefined) this.managerUserVisible = true;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/**
|
|
1836
|
+
* General quiet completion:
|
|
1837
|
+
* - if notify already sent → drop Thinking… bubble
|
|
1838
|
+
* - else if direct user ask and clean prose → one short fallback message
|
|
1839
|
+
* - else → delete Thinking… and stay silent
|
|
1840
|
+
*/
|
|
1841
|
+
private async finishManagerUserFacing(
|
|
1842
|
+
metaTurn: boolean,
|
|
1843
|
+
stopReason: string | undefined,
|
|
1844
|
+
startedAt: number,
|
|
1845
|
+
): Promise<void> {
|
|
1846
|
+
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
1847
|
+
if (this.cancelled || stopReason === "cancelled") {
|
|
1848
|
+
this.lastCompletion = `\u23F9 Stopped \u00B7 ${elapsed}`;
|
|
1849
|
+
// Only surface cancel if user was waiting on a visible bubble.
|
|
1850
|
+
if (this.managerStatusMsgId !== undefined && !metaTurn) {
|
|
1851
|
+
await this.editManagerStatus(this.managerStatusMsgId, this.lastCompletion);
|
|
1852
|
+
this.managerUserVisible = true;
|
|
1853
|
+
} else if (this.managerStatusMsgId !== undefined) {
|
|
1854
|
+
await this.deleteManagerStatus(this.managerStatusMsgId);
|
|
1855
|
+
}
|
|
1856
|
+
this.managerStatusMsgId = undefined;
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// Prose already landed in the Thinking… bubble via the streamer — keep it.
|
|
1861
|
+
if (this.streamer?.hasOutput) {
|
|
1862
|
+
this.managerUserVisible = true;
|
|
1863
|
+
this.lastCompletion =
|
|
1864
|
+
cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
|
|
1865
|
+
`\u2705 Done \u00B7 ${elapsed}`;
|
|
1866
|
+
this.managerStatusMsgId = undefined;
|
|
1867
|
+
return;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (this.managerNotifyCount > 0) {
|
|
1871
|
+
this.lastCompletion =
|
|
1872
|
+
cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
|
|
1873
|
+
`\u2705 Done \u00B7 ${elapsed}`;
|
|
1874
|
+
if (this.managerStatusMsgId !== undefined) {
|
|
1875
|
+
await this.deleteManagerStatus(this.managerStatusMsgId);
|
|
1876
|
+
this.managerStatusMsgId = undefined;
|
|
1877
|
+
}
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
// Work-report / recheck: silent unless cancelled (handled above).
|
|
1882
|
+
if (metaTurn) {
|
|
1883
|
+
this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
|
|
1884
|
+
if (this.managerStatusMsgId !== undefined) {
|
|
1885
|
+
await this.deleteManagerStatus(this.managerStatusMsgId);
|
|
1886
|
+
this.managerStatusMsgId = undefined;
|
|
1887
|
+
}
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1891
|
+
// Direct user ask: single fallback if the model emitted no visible prose.
|
|
1892
|
+
const cleaned = cleanManagerVisibleText(this.turnAssistantText);
|
|
1893
|
+
const fallback = pickManagerFallbackText(cleaned);
|
|
1894
|
+
if (fallback && this.managerStatusMsgId !== undefined) {
|
|
1895
|
+
await this.editManagerStatus(this.managerStatusMsgId, fallback);
|
|
1896
|
+
this.managerUserVisible = true;
|
|
1897
|
+
this.lastCompletion = fallback.slice(0, 500);
|
|
1898
|
+
if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
|
|
1899
|
+
this.managerStatusMsgId = undefined;
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
if (fallback && this.turnReplyTo !== undefined) {
|
|
1903
|
+
const id = await this.notify(fallback, {
|
|
1904
|
+
loud: false,
|
|
1905
|
+
replyTo: this.turnReplyTo,
|
|
1906
|
+
});
|
|
1907
|
+
if (id !== undefined) {
|
|
1908
|
+
this.managerUserVisible = true;
|
|
1909
|
+
this.lastCompletion = fallback.slice(0, 500);
|
|
1910
|
+
if (this.sessionId) this.onTelegramMessageBound?.(id, this.sessionId);
|
|
1911
|
+
}
|
|
1912
|
+
} else if (this.managerStatusMsgId !== undefined) {
|
|
1913
|
+
// Never delete the placeholder leaving the user with no reply.
|
|
1914
|
+
await this.editManagerStatus(
|
|
1915
|
+
this.managerStatusMsgId,
|
|
1916
|
+
"Working on it \u2014 I\u2019ll report back here.",
|
|
1917
|
+
);
|
|
1918
|
+
this.managerUserVisible = true;
|
|
1919
|
+
this.lastCompletion = "Working on it";
|
|
1920
|
+
if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
|
|
1921
|
+
this.managerStatusMsgId = undefined;
|
|
1922
|
+
return;
|
|
1923
|
+
} else {
|
|
1924
|
+
this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
|
|
1925
|
+
}
|
|
1926
|
+
if (this.managerStatusMsgId !== undefined) {
|
|
1927
|
+
await this.deleteManagerStatus(this.managerStatusMsgId);
|
|
1928
|
+
this.managerStatusMsgId = undefined;
|
|
1929
|
+
}
|
|
1089
1930
|
}
|
|
1090
1931
|
|
|
1091
1932
|
/**
|
|
@@ -1142,14 +1983,12 @@ export class SessionRuntime {
|
|
|
1142
1983
|
): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
|
|
1143
1984
|
if (!this.sessionId) return { needed: false, reason: "no session" };
|
|
1144
1985
|
const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
|
|
1145
|
-
this.capturingQuiet = true;
|
|
1146
|
-
this.quietCaptureBuf = "";
|
|
1147
1986
|
try {
|
|
1148
|
-
await this.
|
|
1149
|
-
return parseSelfRecheckDecision(
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1987
|
+
const raw = await this.runQuietPrompt(prompt);
|
|
1988
|
+
return parseSelfRecheckDecision(raw);
|
|
1989
|
+
} catch (e) {
|
|
1990
|
+
log.debug(`self-recheck decision quiet prompt failed: ${(e as Error).message}`);
|
|
1991
|
+
return { needed: false, reason: "decision prompt failed" };
|
|
1153
1992
|
}
|
|
1154
1993
|
}
|
|
1155
1994
|
|
|
@@ -1164,15 +2003,60 @@ export class SessionRuntime {
|
|
|
1164
2003
|
const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
|
|
1165
2004
|
const did = didParts.join("\n") || this.turnAssistantText;
|
|
1166
2005
|
const prompt = buildSuggestionsPrompt(user, did);
|
|
2006
|
+
try {
|
|
2007
|
+
const raw = await this.runQuietPrompt(prompt);
|
|
2008
|
+
return parseSuggestions(raw);
|
|
2009
|
+
} catch (e) {
|
|
2010
|
+
log.debug(`suggestions quiet prompt failed: ${(e as Error).message}`);
|
|
2011
|
+
return [];
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Run a quiet meta ACP prompt (JSON only) with a hard timeout.
|
|
2017
|
+
* On timeout: session/cancel so the shared agent is not stuck holding the
|
|
2018
|
+
* session (which would block Done forever). Does NOT set this.cancelled
|
|
2019
|
+
* (user /stop is separate). Timed-out / partial capture is discarded.
|
|
2020
|
+
*/
|
|
2021
|
+
private async runQuietPrompt(text: string): Promise<string> {
|
|
2022
|
+
if (!this.sessionId) return "";
|
|
2023
|
+
const ms = Math.max(5_000, this.cfg.quietPromptTimeoutMs || 90_000);
|
|
1167
2024
|
this.capturingQuiet = true;
|
|
1168
2025
|
this.quietCaptureBuf = "";
|
|
2026
|
+
let timedOut = false;
|
|
2027
|
+
let settled = false;
|
|
2028
|
+
const timer = setTimeout(() => {
|
|
2029
|
+
// Ignore timer if the prompt already settled (avoids discarding a good JSON
|
|
2030
|
+
// reply that finished in the same tick as the timeout).
|
|
2031
|
+
if (settled) return;
|
|
2032
|
+
timedOut = true;
|
|
2033
|
+
log.warn(
|
|
2034
|
+
`chat ${this.chatId}: quiet meta prompt timed out after ${ms}ms — cancelling session prompt`,
|
|
2035
|
+
);
|
|
2036
|
+
// Session-scoped cancel only — never kill the shared agent process.
|
|
2037
|
+
void this.acp.cancel(this.sessionId!);
|
|
2038
|
+
}, ms);
|
|
2039
|
+
let buf = "";
|
|
1169
2040
|
try {
|
|
1170
|
-
await this.acp.prompt(this.sessionId, [{ type: "text", text
|
|
1171
|
-
|
|
2041
|
+
await this.acp.prompt(this.sessionId, [{ type: "text", text }]);
|
|
2042
|
+
settled = true;
|
|
2043
|
+
clearTimeout(timer);
|
|
2044
|
+
buf = this.quietCaptureBuf;
|
|
2045
|
+
} catch (e) {
|
|
2046
|
+
settled = true;
|
|
2047
|
+
clearTimeout(timer);
|
|
2048
|
+
buf = this.quietCaptureBuf;
|
|
2049
|
+
if (!timedOut) throw e;
|
|
2050
|
+
log.debug(`quiet prompt ended after timeout: ${(e as Error).message}`);
|
|
1172
2051
|
} finally {
|
|
2052
|
+
clearTimeout(timer);
|
|
1173
2053
|
this.capturingQuiet = false;
|
|
1174
2054
|
this.quietCaptureBuf = "";
|
|
1175
2055
|
}
|
|
2056
|
+
// Timed-out meta replies are often half-JSON — skip rather than act on garbage.
|
|
2057
|
+
// If we settled successfully before the timer fired, timedOut stays false.
|
|
2058
|
+
if (timedOut) return "";
|
|
2059
|
+
return buf;
|
|
1176
2060
|
}
|
|
1177
2061
|
|
|
1178
2062
|
/** Resolve a tapped suggestion button; returns the prompt text or undefined. */
|
|
@@ -1270,7 +2154,8 @@ export class SessionRuntime {
|
|
|
1270
2154
|
reasoning: reasoningDirective(this.reasoning),
|
|
1271
2155
|
priming: this.primingContext,
|
|
1272
2156
|
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1273
|
-
progress:
|
|
2157
|
+
progress:
|
|
2158
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1274
2159
|
});
|
|
1275
2160
|
this.primingContext = undefined;
|
|
1276
2161
|
log.info(
|
|
@@ -1333,7 +2218,8 @@ export class SessionRuntime {
|
|
|
1333
2218
|
reasoning: reasoningDirective(this.reasoning),
|
|
1334
2219
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
1335
2220
|
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1336
|
-
progress:
|
|
2221
|
+
progress:
|
|
2222
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1337
2223
|
});
|
|
1338
2224
|
return this.runPromptWithRetries(forkContent);
|
|
1339
2225
|
}
|
|
@@ -1385,7 +2271,8 @@ export class SessionRuntime {
|
|
|
1385
2271
|
reasoning: reasoningDirective(this.reasoning),
|
|
1386
2272
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
1387
2273
|
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1388
|
-
progress:
|
|
2274
|
+
progress:
|
|
2275
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1389
2276
|
});
|
|
1390
2277
|
return this.runPromptWithRetries(content);
|
|
1391
2278
|
}
|
|
@@ -1432,7 +2319,8 @@ export class SessionRuntime {
|
|
|
1432
2319
|
reasoning: reasoningDirective(this.reasoning),
|
|
1433
2320
|
priming: transcript ? buildPriming(transcript) : undefined,
|
|
1434
2321
|
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1435
|
-
progress:
|
|
2322
|
+
progress:
|
|
2323
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1436
2324
|
});
|
|
1437
2325
|
log.info(
|
|
1438
2326
|
`chat ${this.chatId} auto-rotating to account ${t.label}` +
|
|
@@ -1479,6 +2367,11 @@ export class SessionRuntime {
|
|
|
1479
2367
|
try {
|
|
1480
2368
|
const updatesBeforePrompt = this.sessionUpdateCount;
|
|
1481
2369
|
const result = await this.acp.prompt(this.sessionId!, content);
|
|
2370
|
+
// User /stop force-complete or agent honouring session/cancel often
|
|
2371
|
+
// returns cancelled with zero session/update chunks — that is success.
|
|
2372
|
+
if (this.cancelled || result?.stopReason === "cancelled") {
|
|
2373
|
+
return { result: result ?? { stopReason: "cancelled" }, attempts: attempt };
|
|
2374
|
+
}
|
|
1482
2375
|
// A healthy ACP turn emits at least one session/update (text, thought,
|
|
1483
2376
|
// or tool event) before resolving session/prompt. Grok can otherwise
|
|
1484
2377
|
// report a successful end-turn after an upstream model failure; never
|
|
@@ -1557,7 +2450,8 @@ export class SessionRuntime {
|
|
|
1557
2450
|
const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
|
|
1558
2451
|
reasoning: reasoningDirective(this.reasoning),
|
|
1559
2452
|
imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
|
|
1560
|
-
progress:
|
|
2453
|
+
progress:
|
|
2454
|
+
!this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
|
|
1561
2455
|
});
|
|
1562
2456
|
|
|
1563
2457
|
let last = final;
|
|
@@ -1604,6 +2498,7 @@ export class SessionRuntime {
|
|
|
1604
2498
|
already: this.sentImagesThisTurn,
|
|
1605
2499
|
max: this.cfg.agentImagesMax,
|
|
1606
2500
|
replyTo: this.turnReplyTo,
|
|
2501
|
+
messageThreadId: this.messageThreadId,
|
|
1607
2502
|
});
|
|
1608
2503
|
if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
|
|
1609
2504
|
} catch {
|
|
@@ -1627,6 +2522,111 @@ export class SessionRuntime {
|
|
|
1627
2522
|
return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
|
|
1628
2523
|
}
|
|
1629
2524
|
|
|
2525
|
+
/** Soft completion for General manager (no file-ops / progress spam). */
|
|
2526
|
+
private managerCompletionMessage(
|
|
2527
|
+
stopReason: string | undefined,
|
|
2528
|
+
startedAt: number,
|
|
2529
|
+
streamedOutput: boolean,
|
|
2530
|
+
): string {
|
|
2531
|
+
const elapsed = fmtDuration(Date.now() - startedAt);
|
|
2532
|
+
if (this.cancelled || stopReason === "cancelled") {
|
|
2533
|
+
const msg = `\u23F9 Stopped \u00B7 ${elapsed}`;
|
|
2534
|
+
this.lastCompletion = msg;
|
|
2535
|
+
return streamedOutput ? "" : msg;
|
|
2536
|
+
}
|
|
2537
|
+
// When prose already streamed, no extra Done line (chat-like).
|
|
2538
|
+
if (streamedOutput) {
|
|
2539
|
+
this.lastCompletion = this.turnAssistantText.slice(0, 800) || `\u2705 Done \u00B7 ${elapsed}`;
|
|
2540
|
+
return "";
|
|
2541
|
+
}
|
|
2542
|
+
const msg = `\u2705 Done \u00B7 ${elapsed}`;
|
|
2543
|
+
this.lastCompletion = msg;
|
|
2544
|
+
return msg;
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
/**
|
|
2548
|
+
* True when the next queued prompt is a continuation of the *same* manager
|
|
2549
|
+
* job (self-recheck / bridge results / suggestion batch), not a new user
|
|
2550
|
+
* ask or a different send_prompt dispatch.
|
|
2551
|
+
*/
|
|
2552
|
+
private queueIsSameJobContinuation(jobId: string): boolean {
|
|
2553
|
+
if (this.queue.length === 0) return false;
|
|
2554
|
+
const next = this.queue[0]!;
|
|
2555
|
+
if (next.reportBack && next.reportBack.jobId !== jobId) return false;
|
|
2556
|
+
if (next.reportBack && next.reportBack.jobId === jobId) return true;
|
|
2557
|
+
// Meta continuations omit reportBack but keep the open job.
|
|
2558
|
+
return (
|
|
2559
|
+
!!next.skipSelfRecheck ||
|
|
2560
|
+
isSelfRecheckPrompt(next.text) ||
|
|
2561
|
+
isTelegramBridgeResultsPrompt(next.text) ||
|
|
2562
|
+
isManagerWorkReportPrompt(next.text)
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
/**
|
|
2567
|
+
* If this runtime was dispatched from General, wake the manager with a
|
|
2568
|
+
* structured WORK REPORT once for this job. Waits only for same-job meta
|
|
2569
|
+
* follow-ups; reports immediately when a different dispatch/user turn is next.
|
|
2570
|
+
*/
|
|
2571
|
+
private async maybeReportBackToManager(opts: {
|
|
2572
|
+
ok: boolean;
|
|
2573
|
+
cancelled: boolean;
|
|
2574
|
+
stopReason?: string;
|
|
2575
|
+
error?: string;
|
|
2576
|
+
}): Promise<void> {
|
|
2577
|
+
const meta = this.pendingReportBack;
|
|
2578
|
+
if (!meta || !this.bridge?.wakeManager) return;
|
|
2579
|
+
// Same-job recheck / bridge results / suggestions still pending — wait.
|
|
2580
|
+
if (this.queueIsSameJobContinuation(meta.jobId)) return;
|
|
2581
|
+
|
|
2582
|
+
const status = opts.cancelled ? "cancelled" : opts.ok ? "done" : "failed";
|
|
2583
|
+
const assistantSummary = (
|
|
2584
|
+
this.turnAssistantText.trim() ||
|
|
2585
|
+
this.lastCompletion ||
|
|
2586
|
+
"(no assistant text)"
|
|
2587
|
+
).replace(/\s+/g, " ").trim();
|
|
2588
|
+
const filesSummary =
|
|
2589
|
+
this.fileOps.size > 0 ? summarizeFileOpsShort(this.fileOps) : undefined;
|
|
2590
|
+
|
|
2591
|
+
updateManagerJob(meta.jobId, {
|
|
2592
|
+
status: status === "done" ? "done" : status === "cancelled" ? "cancelled" : "failed",
|
|
2593
|
+
resultSummary: assistantSummary.slice(0, 400),
|
|
2594
|
+
childSessionId: this.sessionId,
|
|
2595
|
+
});
|
|
2596
|
+
|
|
2597
|
+
const prompt = buildManagerWorkReportPrompt({
|
|
2598
|
+
jobId: meta.jobId,
|
|
2599
|
+
targetName: meta.targetName || this.projectName || basename(this.cwd),
|
|
2600
|
+
targetThreadId: this.messageThreadId ?? 0,
|
|
2601
|
+
targetPath: meta.targetPath || this.cwd,
|
|
2602
|
+
userAskPreview: meta.userAskPreview,
|
|
2603
|
+
dispatchPromptPreview: meta.dispatchPrompt,
|
|
2604
|
+
status,
|
|
2605
|
+
stopReason: opts.stopReason,
|
|
2606
|
+
error: opts.error,
|
|
2607
|
+
assistantSummary,
|
|
2608
|
+
filesSummary,
|
|
2609
|
+
childSessionId: this.sessionId,
|
|
2610
|
+
});
|
|
2611
|
+
|
|
2612
|
+
// Clear before await so a re-entry cannot double-report this job.
|
|
2613
|
+
this.pendingReportBack = undefined;
|
|
2614
|
+
try {
|
|
2615
|
+
await this.bridge.wakeManager({
|
|
2616
|
+
originChatId: meta.originChatId,
|
|
2617
|
+
originThreadId: meta.originThreadId,
|
|
2618
|
+
prompt,
|
|
2619
|
+
});
|
|
2620
|
+
log.info(
|
|
2621
|
+
`report-back job ${meta.jobId} → general (#${meta.originThreadId}) status=${status}`,
|
|
2622
|
+
);
|
|
2623
|
+
} catch (e) {
|
|
2624
|
+
log.warn(`report-back failed for job ${meta.jobId}: ${(e as Error).message}`);
|
|
2625
|
+
// Restore so a later turn might retry once if still attached.
|
|
2626
|
+
this.pendingReportBack = meta;
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
|
|
1630
2630
|
/**
|
|
1631
2631
|
* Final Done after a self-recheck: head + split file lists (first turn vs recheck).
|
|
1632
2632
|
*/
|
|
@@ -1693,12 +2693,16 @@ export class SessionRuntime {
|
|
|
1693
2693
|
}
|
|
1694
2694
|
|
|
1695
2695
|
/** Searchable Telegram hashtags so you can pull up every message of a session
|
|
1696
|
-
* or project by tapping the tag. */
|
|
2696
|
+
* or project (and this turn's prompt) by tapping the tag. */
|
|
1697
2697
|
private hashtags(): string {
|
|
2698
|
+
// General manager: no tags (user requested clean chat). Reply routing uses
|
|
2699
|
+
// Telegram message-id → session map, not #sess_ footers.
|
|
2700
|
+
if (this.managerMode) return "";
|
|
1698
2701
|
return sessionHashtags({
|
|
1699
2702
|
projectName: this.projectName,
|
|
1700
2703
|
cwd: this.cwd,
|
|
1701
2704
|
sessionId: this.sessionId,
|
|
2705
|
+
promptId: this.turnPromptId,
|
|
1702
2706
|
});
|
|
1703
2707
|
}
|
|
1704
2708
|
|
|
@@ -1710,11 +2714,17 @@ export class SessionRuntime {
|
|
|
1710
2714
|
const head = this.queue[0]!;
|
|
1711
2715
|
const isMeta =
|
|
1712
2716
|
!!head.skipSelfRecheck ||
|
|
1713
|
-
isSelfRecheckPrompt(head.text)
|
|
2717
|
+
isSelfRecheckPrompt(head.text) ||
|
|
2718
|
+
isTelegramBridgeResultsPrompt(head.text);
|
|
1714
2719
|
const batch = isMeta
|
|
1715
2720
|
? this.queue.shift()!
|
|
1716
2721
|
: mergeInputs(this.queue.splice(0, this.queue.length));
|
|
1717
|
-
|
|
2722
|
+
// Real user follow-ups only — never spam chat for bridge/recheck meta turns.
|
|
2723
|
+
if (this.foreground && !isMeta) {
|
|
2724
|
+
await this.notify("\u25B6\uFE0F Processing queued message\u2026", {
|
|
2725
|
+
replyTo: batch.replyTo,
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
1718
2728
|
void this.runTurn(batch);
|
|
1719
2729
|
}
|
|
1720
2730
|
|
|
@@ -1761,7 +2771,10 @@ export class SessionRuntime {
|
|
|
1761
2771
|
}
|
|
1762
2772
|
} else if (kind === "agent_thought_chunk") {
|
|
1763
2773
|
const text = contentText(update.content);
|
|
1764
|
-
if (text?.trim())
|
|
2774
|
+
if (text?.trim()) {
|
|
2775
|
+
this.appendCardThinking(text);
|
|
2776
|
+
this.setLiveStep(stepFromThought(text));
|
|
2777
|
+
}
|
|
1765
2778
|
} else if (kind === "plan") {
|
|
1766
2779
|
// Always track plan entries (background too) so switch-to-live restores the board.
|
|
1767
2780
|
const entries = parsePlanUpdate(update);
|
|
@@ -1836,7 +2849,7 @@ export class SessionRuntime {
|
|
|
1836
2849
|
|
|
1837
2850
|
private persist(): void {
|
|
1838
2851
|
if (!this.foreground) return; // only the foreground session is the chat's restored default
|
|
1839
|
-
this.settings.
|
|
2852
|
+
this.settings.updateKey(this.settingsKey, {
|
|
1840
2853
|
projectPath: this.cwd,
|
|
1841
2854
|
projectName: this.projectName,
|
|
1842
2855
|
sessionId: this.sessionId,
|
|
@@ -1859,20 +2872,65 @@ export class SessionRuntime {
|
|
|
1859
2872
|
}
|
|
1860
2873
|
}
|
|
1861
2874
|
|
|
2875
|
+
/**
|
|
2876
|
+
* Send a chat message. Returns Telegram message_id on success.
|
|
2877
|
+
* On failure, retries once truncated (~3500) without reply_markup so a long
|
|
2878
|
+
* Done / markup error cannot silently drop the completion ping.
|
|
2879
|
+
*/
|
|
1862
2880
|
private async notify(
|
|
1863
2881
|
text: string,
|
|
1864
2882
|
opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
|
|
2883
|
+
): Promise<number | undefined> {
|
|
2884
|
+
const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
|
|
2885
|
+
try {
|
|
2886
|
+
const extra: Record<string, unknown> = {
|
|
2887
|
+
...(opts?.loud ? { disable_notification: false } : {}),
|
|
2888
|
+
// Never pass message_thread_id=1 (General) — Telegram rejects it.
|
|
2889
|
+
...outboundThreadExtra(this.messageThreadId),
|
|
2890
|
+
};
|
|
2891
|
+
if (opts?.replyTo !== undefined) {
|
|
2892
|
+
extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
|
|
2893
|
+
}
|
|
2894
|
+
if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
|
|
2895
|
+
const msg = await this.api.sendMessage(this.chatId, body, extra);
|
|
2896
|
+
if (this.sessionId) this.onTelegramMessageBound?.(msg.message_id, this.sessionId);
|
|
2897
|
+
return msg.message_id;
|
|
2898
|
+
} catch (e) {
|
|
2899
|
+
log.debug("notify failed:", (e as Error).message);
|
|
2900
|
+
return undefined;
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
const id = await send(text, true);
|
|
2904
|
+
if (id !== undefined) return id;
|
|
2905
|
+
const short = text.length > 3500 ? text.slice(0, 3499) + "\u2026" : text;
|
|
2906
|
+
return send(short, false);
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
/**
|
|
2910
|
+
* After Done was already sent, attach suggestion text + buttons by editing
|
|
2911
|
+
* that message (or sending a follow-up if edit fails / no message id).
|
|
2912
|
+
*/
|
|
2913
|
+
private async enhanceDoneMessage(
|
|
2914
|
+
messageId: number | undefined,
|
|
2915
|
+
text: string,
|
|
2916
|
+
markup: InlineKeyboard | undefined,
|
|
1865
2917
|
): Promise<void> {
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
extra
|
|
2918
|
+
if (messageId !== undefined) {
|
|
2919
|
+
try {
|
|
2920
|
+
// editMessageText does not need message_thread_id; keep markup only.
|
|
2921
|
+
const extra: Record<string, unknown> = {};
|
|
2922
|
+
if (markup) extra.reply_markup = markup;
|
|
2923
|
+
await this.api.editMessageText(this.chatId, messageId, text, extra);
|
|
2924
|
+
return;
|
|
2925
|
+
} catch (e) {
|
|
2926
|
+
log.debug("enhanceDoneMessage edit failed:", (e as Error).message);
|
|
1870
2927
|
}
|
|
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
2928
|
}
|
|
2929
|
+
await this.notify(text, {
|
|
2930
|
+
loud: false,
|
|
2931
|
+
replyTo: this.turnReplyTo,
|
|
2932
|
+
replyMarkup: markup,
|
|
2933
|
+
});
|
|
1876
2934
|
}
|
|
1877
2935
|
|
|
1878
2936
|
private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
|
|
@@ -1885,8 +2943,47 @@ export class SessionRuntime {
|
|
|
1885
2943
|
})
|
|
1886
2944
|
.filter(Boolean)
|
|
1887
2945
|
.join("\n\n");
|
|
1888
|
-
if (body.trim())
|
|
2946
|
+
if (body.trim()) {
|
|
2947
|
+
await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`, {
|
|
2948
|
+
messageThreadId: this.messageThreadId,
|
|
2949
|
+
});
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
/** Visible manager reply body (no progress markers / telegram action fences). */
|
|
2955
|
+
function cleanManagerVisibleText(raw: string): string {
|
|
2956
|
+
if (!raw?.trim()) return "";
|
|
2957
|
+
const withoutTg = stripTelegramActionFences(raw);
|
|
2958
|
+
return extractProgress(withoutTg).cleaned.trim();
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
/**
|
|
2962
|
+
* One short user-facing fallback when General forgot `notify`.
|
|
2963
|
+
* Drops empty, table-spam, and pure status narration.
|
|
2964
|
+
*/
|
|
2965
|
+
export function pickManagerFallbackText(cleaned: string): string | undefined {
|
|
2966
|
+
let t = cleaned.replace(/\r\n/g, "\n").trim();
|
|
2967
|
+
if (!t) return undefined;
|
|
2968
|
+
// Drop markdown tables and multi-line job dumps.
|
|
2969
|
+
if (/^\s*\|.+\|/m.test(t) && (t.match(/\|/g) || []).length >= 6) return undefined;
|
|
2970
|
+
// Collapse whitespace.
|
|
2971
|
+
t = t.replace(/\n{3,}/g, "\n\n").trim();
|
|
2972
|
+
// Prefer first 1–2 short paragraphs.
|
|
2973
|
+
const paras = t.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
|
|
2974
|
+
let out = paras.slice(0, 2).join("\n\n");
|
|
2975
|
+
if (out.length > 600) out = out.slice(0, 597) + "\u2026";
|
|
2976
|
+
// Ignore pure meta / empty placeholders.
|
|
2977
|
+
if (/^(thinking|starting|ok|done|\.+|\u2026)+$/i.test(out.trim())) return undefined;
|
|
2978
|
+
if (out.length < 2) return undefined;
|
|
2979
|
+
// Skip "Dispatching… / Sending to…" spam patterns if that is all we got.
|
|
2980
|
+
if (
|
|
2981
|
+
/^(dispatching|sending to|queued|cancelling|already running)\b/i.test(out) &&
|
|
2982
|
+
out.length < 280
|
|
2983
|
+
) {
|
|
2984
|
+
return undefined;
|
|
1889
2985
|
}
|
|
2986
|
+
return out;
|
|
1890
2987
|
}
|
|
1891
2988
|
|
|
1892
2989
|
/** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */
|