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.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Discover a best-effort project icon (favicon, web assets, MSIX/store logos).
3
+ * Used when creating forum topics: Telegram cannot set arbitrary topic avatars
4
+ * from files, so we pin the image inside the topic as a visual stand-in.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
7
+ import { basename, extname, join } from "node:path";
8
+
9
+ const IMAGE_EXT = new Set([".ico", ".png", ".jpg", ".jpeg", ".webp", ".svg", ".gif"]);
10
+
11
+ const ROOT_CANDIDATES = [
12
+ "favicon.ico",
13
+ "favicon.png",
14
+ "favicon.svg",
15
+ "apple-touch-icon.png",
16
+ "apple-touch-icon-precomposed.png",
17
+ "logo.png",
18
+ "logo.svg",
19
+ "icon.png",
20
+ "icon.ico",
21
+ "app-icon.png",
22
+ ];
23
+
24
+ const SUBDIR_CANDIDATES = [
25
+ ["public", "favicon.ico"],
26
+ ["public", "favicon.png"],
27
+ ["public", "apple-touch-icon.png"],
28
+ ["static", "favicon.ico"],
29
+ ["assets", "favicon.ico"],
30
+ ["assets", "favicon.png"],
31
+ ["assets", "logo.png"],
32
+ ["Assets", "StoreLogo.png"],
33
+ ["Assets", "Square44x44Logo.png"],
34
+ ["Assets", "Square150x150Logo.png"],
35
+ ["Assets", "LockScreenLogo.png"],
36
+ ["Images", "logo.png"],
37
+ ["images", "logo.png"],
38
+ ["images", "icon.png"],
39
+ ["src", "assets", "logo.png"],
40
+ ["src", "assets", "favicon.ico"],
41
+ ];
42
+
43
+ /** Prefer larger / more “logo-like” MSIX asset names. */
44
+ const MSIX_NAME_RE =
45
+ /StoreLogo|Square\d+x\d+Logo|Wide\d+x\d+Logo|BadgeLogo|AppList|logo|icon|favicon/i;
46
+
47
+ /**
48
+ * Return the best absolute icon path for a project directory, or undefined.
49
+ */
50
+ export function discoverProjectIcon(projectPath: string): string | undefined {
51
+ if (!projectPath || !existsSync(projectPath)) return undefined;
52
+
53
+ for (const rel of ROOT_CANDIDATES) {
54
+ const p = join(projectPath, rel);
55
+ if (isImageFile(p)) return p;
56
+ }
57
+ for (const parts of SUBDIR_CANDIDATES) {
58
+ const p = join(projectPath, ...parts);
59
+ if (isImageFile(p)) return p;
60
+ }
61
+
62
+ // MSIX / WinUI: scan Assets for logo-like files.
63
+ const assetsDir = join(projectPath, "Assets");
64
+ const fromAssets = pickBestImageInDir(assetsDir);
65
+ if (fromAssets) return fromAssets;
66
+
67
+ // Package.appxmanifest Logo="Assets\..."
68
+ const fromManifest = iconFromAppxManifest(projectPath);
69
+ if (fromManifest) return fromManifest;
70
+
71
+ // Store listing folders (common in this workspace).
72
+ for (const sub of ["StoreListing", "store-listing", "listing", "media"]) {
73
+ const hit = pickBestImageInDir(join(projectPath, sub));
74
+ if (hit) return hit;
75
+ }
76
+
77
+ return undefined;
78
+ }
79
+
80
+ function iconFromAppxManifest(projectPath: string): string | undefined {
81
+ const manifest = join(projectPath, "Package.appxmanifest");
82
+ if (!existsSync(manifest)) return undefined;
83
+ let xml: string;
84
+ try {
85
+ xml = readFileSync(manifest, "utf-8");
86
+ } catch {
87
+ return undefined;
88
+ }
89
+ // Logo="Assets\StoreLogo.png" or Logo="Assets/StoreLogo.png"
90
+ const re = /\b(?:Logo|Square\d+x\d+Logo|Wide\d+x\d+Logo|StoreLogo)\s*=\s*"([^"]+)"/gi;
91
+ let m: RegExpExecArray | null;
92
+ const candidates: string[] = [];
93
+ while ((m = re.exec(xml))) {
94
+ const rel = m[1]!.replace(/\\/g, "/");
95
+ candidates.push(join(projectPath, rel));
96
+ }
97
+ for (const p of candidates) {
98
+ if (isImageFile(p)) return p;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ function pickBestImageInDir(dir: string): string | undefined {
104
+ let names: string[];
105
+ try {
106
+ names = readdirSync(dir);
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ const scored: Array<{ path: string; score: number; size: number }> = [];
111
+ for (const name of names) {
112
+ const p = join(dir, name);
113
+ if (!isImageFile(p)) continue;
114
+ let size = 0;
115
+ try {
116
+ size = statSync(p).size;
117
+ } catch {
118
+ continue;
119
+ }
120
+ let score = 0;
121
+ if (MSIX_NAME_RE.test(name)) score += 50;
122
+ if (/StoreLogo/i.test(name)) score += 30;
123
+ if (/favicon/i.test(name)) score += 40;
124
+ if (extname(name).toLowerCase() === ".png") score += 5;
125
+ // Prefer mid-size icons over tiny badges / huge splash.
126
+ if (size > 2_000 && size < 500_000) score += 10;
127
+ scored.push({ path: p, score, size });
128
+ }
129
+ if (scored.length === 0) return undefined;
130
+ scored.sort((a, b) => b.score - a.score || b.size - a.size);
131
+ return scored[0]!.path;
132
+ }
133
+
134
+ function isImageFile(p: string): boolean {
135
+ if (!existsSync(p)) return false;
136
+ try {
137
+ if (!statSync(p).isFile()) return false;
138
+ } catch {
139
+ return false;
140
+ }
141
+ return IMAGE_EXT.has(extname(p).toLowerCase()) || basename(p).toLowerCase() === "favicon.ico";
142
+ }
@@ -0,0 +1,49 @@
1
+ /** Telegram General forum topic id (always 1). */
2
+ export const FORUM_GENERAL_THREAD_ID = 1;
3
+
4
+ /**
5
+ * Batch/runtime key for a chat. Private chats use thread 0.
6
+ * Forum messages without message_thread_id are treated as General (1).
7
+ */
8
+ export function batchKey(chatId: number, threadId: number | undefined, isForumGroup: boolean): string {
9
+ if (!isForumGroup) return `${chatId}:0`;
10
+ return `${chatId}:${threadId ?? FORUM_GENERAL_THREAD_ID}`;
11
+ }
12
+
13
+ /** Normalize forum thread id (undefined → General). */
14
+ export function forumThreadId(threadId: number | undefined): number {
15
+ return threadId ?? FORUM_GENERAL_THREAD_ID;
16
+ }
17
+
18
+ /**
19
+ * True when this forum thread is the General manager topic.
20
+ * Only exact id `1` — do NOT treat private chats (undefined) as General.
21
+ * Callers should pass {@link forumThreadId} first when reading raw Telegram ids.
22
+ */
23
+ export function isGeneralThread(threadId: number | undefined): boolean {
24
+ return threadId === FORUM_GENERAL_THREAD_ID;
25
+ }
26
+
27
+ /**
28
+ * Thread id for **outbound** Telegram API calls (`sendMessage`, `editMessage`, …).
29
+ *
30
+ * Critical: Bot API often rejects `message_thread_id: 1` for the General forum
31
+ * topic with "Bad Request: message thread not found". Omitting the field posts
32
+ * to General correctly. Real project topics (id > 1) must still pass the id.
33
+ *
34
+ * Inbound routing still uses {@link forumThreadId} (undefined → 1).
35
+ */
36
+ export function outboundMessageThreadId(
37
+ threadId: number | undefined,
38
+ ): number | undefined {
39
+ if (threadId === undefined || threadId === FORUM_GENERAL_THREAD_ID) return undefined;
40
+ return threadId;
41
+ }
42
+
43
+ /** Extra object for send/edit: only includes message_thread_id when safe. */
44
+ export function outboundThreadExtra(
45
+ threadId: number | undefined,
46
+ ): { message_thread_id?: number } {
47
+ const id = outboundMessageThreadId(threadId);
48
+ return id !== undefined ? { message_thread_id: id } : {};
49
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Persist forum topic ↔ project bindings under the bot data directory.
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import type { ForumTopicBinding, ForumTopicState, TopicKind } from "./types.js";
7
+
8
+ export class TopicStore {
9
+ private readonly file: string;
10
+ private state: ForumTopicState;
11
+
12
+ constructor(dataDir: string, groupId: number) {
13
+ mkdirSync(dataDir, { recursive: true });
14
+ this.file = join(dataDir, `forum-topics-${groupId}.json`);
15
+ this.state = this.load(groupId);
16
+ }
17
+
18
+ get groupId(): number {
19
+ return this.state.groupId;
20
+ }
21
+
22
+ all(): ForumTopicBinding[] {
23
+ return Object.values(this.state.topics);
24
+ }
25
+
26
+ get(threadId: number): ForumTopicBinding | undefined {
27
+ return this.state.topics[String(threadId)];
28
+ }
29
+
30
+ upsert(binding: ForumTopicBinding): void {
31
+ this.state.topics[String(binding.threadId)] = { ...binding, updatedAt: Date.now() };
32
+ this.save();
33
+ }
34
+
35
+ bindProject(threadId: number, projectPath: string, name?: string, kind: TopicKind = "project"): ForumTopicBinding {
36
+ const prev = this.get(threadId);
37
+ const next: ForumTopicBinding = {
38
+ threadId,
39
+ name: name || prev?.name || basenamePath(projectPath),
40
+ kind,
41
+ projectPath,
42
+ iconPath: prev?.iconPath,
43
+ sessionId: prev?.sessionId,
44
+ updatedAt: Date.now(),
45
+ };
46
+ this.upsert(next);
47
+ this.clearPending(threadId);
48
+ return next;
49
+ }
50
+
51
+ markPending(threadId: number): void {
52
+ if (!this.state.pendingBind.includes(threadId)) {
53
+ this.state.pendingBind.push(threadId);
54
+ this.save();
55
+ }
56
+ }
57
+
58
+ isPending(threadId: number): boolean {
59
+ return this.state.pendingBind.includes(threadId);
60
+ }
61
+
62
+ clearPending(threadId: number): void {
63
+ this.state.pendingBind = this.state.pendingBind.filter((id) => id !== threadId);
64
+ this.save();
65
+ }
66
+
67
+ findByProjectPath(projectPath: string): ForumTopicBinding | undefined {
68
+ const key = norm(projectPath);
69
+ return this.all().find((t) => t.projectPath && norm(t.projectPath) === key);
70
+ }
71
+
72
+ findAiChat(): ForumTopicBinding | undefined {
73
+ return this.all().find((t) => t.kind === "ai_chat");
74
+ }
75
+
76
+ setLastSetup(): void {
77
+ this.state.lastSetupAt = Date.now();
78
+ this.save();
79
+ }
80
+
81
+ private load(groupId: number): ForumTopicState {
82
+ if (!existsSync(this.file)) {
83
+ return { groupId, topics: {}, pendingBind: [] };
84
+ }
85
+ try {
86
+ const raw = JSON.parse(readFileSync(this.file, "utf-8")) as Partial<ForumTopicState>;
87
+ return {
88
+ groupId: typeof raw.groupId === "number" ? raw.groupId : groupId,
89
+ topics: (raw.topics && typeof raw.topics === "object" ? raw.topics : {}) as Record<
90
+ string,
91
+ ForumTopicBinding
92
+ >,
93
+ pendingBind: Array.isArray(raw.pendingBind) ? raw.pendingBind.filter((n) => typeof n === "number") : [],
94
+ lastSetupAt: typeof raw.lastSetupAt === "number" ? raw.lastSetupAt : undefined,
95
+ };
96
+ } catch {
97
+ return { groupId, topics: {}, pendingBind: [] };
98
+ }
99
+ }
100
+
101
+ private save(): void {
102
+ writeFileSync(this.file, JSON.stringify(this.state, null, 2), "utf-8");
103
+ }
104
+ }
105
+
106
+ function norm(p: string): string {
107
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
108
+ }
109
+
110
+ function basenamePath(p: string): string {
111
+ const n = p.replace(/\\/g, "/").replace(/\/+$/, "");
112
+ const i = n.lastIndexOf("/");
113
+ return i >= 0 ? n.slice(i + 1) : n;
114
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Forum topic ↔ project mapping types (one Telegram forum group).
3
+ */
4
+
5
+ export type TopicKind = "ai_chat" | "project" | "general" | "unbound";
6
+
7
+ export interface ForumTopicBinding {
8
+ /** Telegram message_thread_id */
9
+ threadId: number;
10
+ /** Topic title when last seen / created */
11
+ name: string;
12
+ kind: TopicKind;
13
+ /** Absolute project path, or workspace for AI chat. Null when unbound. */
14
+ projectPath: string | null;
15
+ /** Best-effort icon file path (favicon / MSIX logo), if discovered. */
16
+ iconPath?: string;
17
+ /** Last Grok ACP session id bound to this topic (optional resume hint). */
18
+ sessionId?: string;
19
+ updatedAt: number;
20
+ }
21
+
22
+ export interface ForumTopicState {
23
+ groupId: number;
24
+ topics: Record<string, ForumTopicBinding>; // key = String(threadId)
25
+ /** Threads waiting for the user to provide a project path. */
26
+ pendingBind: number[];
27
+ /** Last successful auto-setup time (ms). */
28
+ lastSetupAt?: number;
29
+ }
@@ -225,8 +225,14 @@ interface Pending {
225
225
  reject: (e: Error) => void;
226
226
  cleanup: () => void;
227
227
  method: string;
228
+ /** Set for in-flight `session/prompt` so cancel can target one session only. */
229
+ sessionId?: string;
228
230
  }
229
231
 
232
+ /** How long we wait for the agent to honour `session/cancel` before force-completing
233
+ * that session's prompt locally (other sessions are never touched). */
234
+ export const CANCEL_FORCE_MS = 2_000;
235
+
230
236
  export declare interface GrokClient {
231
237
  on(e: "session-update", l: (sessionId: string, update: SessionUpdate) => void): this;
232
238
  on(e: "notification", l: (method: string, params: unknown) => void): this;
@@ -258,6 +264,10 @@ export class GrokClient extends EventEmitter {
258
264
  private readonly cwd = new Map<string, string>();
259
265
  /** Sessions with an in-flight prompt (drives "active"). */
260
266
  private readonly running = new Set<string>();
267
+ /** In-flight prompt request id per session (at most one prompt per session). */
268
+ private readonly promptReqBySession = new Map<string, number | string>();
269
+ /** Timers that force-complete a cancelled prompt if the agent is slow. */
270
+ private readonly cancelForceTimers = new Map<string, NodeJS.Timeout>();
261
271
  /** Accumulated assistant text per in-flight turn (flushed to the log on end). */
262
272
  private readonly assistantBuf = new Map<string, string>();
263
273
  private authMethodId?: string;
@@ -272,6 +282,12 @@ export class GrokClient extends EventEmitter {
272
282
  private subagents: SubagentInfo[] = [];
273
283
  private pendingStages: PendingStage[] = [];
274
284
  permissionHandler?: (params: RequestPermissionParams) => Promise<PermissionOutcome>;
285
+ /**
286
+ * Optional hook when a session is user-cancelled (e.g. cancel pending
287
+ * interactive permission prompts for that session — ACP requires cancelled
288
+ * outcomes). Must never kill the agent process.
289
+ */
290
+ onSessionCancel?: (sessionId: string) => void;
275
291
 
276
292
  constructor(private readonly opts: GrokClientOptions) {
277
293
  super();
@@ -440,8 +456,26 @@ export class GrokClient extends EventEmitter {
440
456
  return new Promise<PromptResult>((resolve, reject) => {
441
457
  const id = this.nextId++;
442
458
  const start = Date.now();
459
+ // Single-settlement guard: force-cancel, agent response, idle/max timeout,
460
+ // and failAllPending must never double-resolve/reject this promise.
461
+ let settled = false;
462
+ const settleResolve = (v: unknown): void => {
463
+ if (settled) return;
464
+ settled = true;
465
+ this.pending.delete(id);
466
+ this.finishPrompt(sessionId, id);
467
+ resolve(v as PromptResult);
468
+ };
469
+ const settleReject = (e: Error): void => {
470
+ if (settled) return;
471
+ settled = true;
472
+ this.pending.delete(id);
473
+ this.finishPrompt(sessionId, id);
474
+ reject(e);
475
+ };
443
476
  this.lastActivity.set(sessionId, start);
444
477
  this.running.add(sessionId);
478
+ this.promptReqBySession.set(sessionId, id);
445
479
  if (this.proc?.pid) this.slog.lock(sessionId, this.proc.pid);
446
480
  const userText = this.cleanUserText(content);
447
481
  this.slog.logUser(sessionId, userText);
@@ -451,48 +485,47 @@ export class GrokClient extends EventEmitter {
451
485
  if (title) this.slog.update(sessionId, { title });
452
486
  }
453
487
  const watch = setInterval(() => {
488
+ if (settled) {
489
+ clearInterval(watch);
490
+ return;
491
+ }
454
492
  const last = Math.max(this.lastActivity.get(sessionId) ?? start, this.lastActivityAny);
455
493
  const idle = Date.now() - last;
456
494
  const total = Date.now() - start;
457
495
  if (total > this.promptMaxMs) {
458
- this.pending.delete(id);
459
- this.finishPrompt(sessionId, id);
460
496
  clearInterval(watch);
497
+ // Settle first so cancel()'s force-complete no-ops (prompt already
498
+ // gone). Still notify the agent — never kill the shared process.
499
+ settleReject(new Error(`Prompt exceeded the ${Math.round(this.promptMaxMs / 60_000)}min cap`));
461
500
  void this.cancel(sessionId);
462
- reject(new Error(`Prompt exceeded the ${Math.round(this.promptMaxMs / 60_000)}min cap`));
463
501
  } else if (idle > this.promptIdleMs) {
464
- this.pending.delete(id);
465
- this.finishPrompt(sessionId, id);
466
502
  clearInterval(watch);
503
+ settleReject(new Error(`No agent activity for ${Math.round(idle / 1000)}s — giving up`));
467
504
  void this.cancel(sessionId);
468
- reject(new Error(`No agent activity for ${Math.round(idle / 1000)}s — giving up`));
469
505
  }
470
506
  }, 15_000);
471
507
  this.pending.set(id, {
472
- resolve: (v) => {
473
- this.finishPrompt(sessionId, id);
474
- resolve(v as PromptResult);
475
- },
476
- reject: (e) => {
477
- this.finishPrompt(sessionId, id);
478
- reject(e);
479
- },
508
+ resolve: settleResolve,
509
+ reject: settleReject,
480
510
  cleanup: () => clearInterval(watch),
481
511
  method: "session/prompt",
512
+ sessionId,
482
513
  });
483
514
  try {
484
515
  this.transport!.send({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: content } });
485
516
  } catch (e) {
486
517
  clearInterval(watch);
487
- this.pending.delete(id);
488
- this.finishPrompt(sessionId, id);
489
- reject(e as Error);
518
+ settleReject(e as Error);
490
519
  }
491
520
  });
492
521
  }
493
522
 
494
523
  /** Clear the running/lock state for a finished turn and flush its transcript. */
495
- private finishPrompt(sessionId: string, _id: number): void {
524
+ private finishPrompt(sessionId: string, id: number | string): void {
525
+ this.clearCancelForce(sessionId);
526
+ if (this.promptReqBySession.get(sessionId) === id) {
527
+ this.promptReqBySession.delete(sessionId);
528
+ }
496
529
  this.running.delete(sessionId);
497
530
  this.slog.unlock(sessionId);
498
531
  const buf = this.assistantBuf.get(sessionId);
@@ -500,12 +533,68 @@ export class GrokClient extends EventEmitter {
500
533
  this.assistantBuf.delete(sessionId);
501
534
  }
502
535
 
536
+ /**
537
+ * Cancel one session's in-flight turn only.
538
+ *
539
+ * - Sends ACP `session/cancel` (agent should respond with stopReason cancelled).
540
+ * - Notifies permission layer so pending interactive prompts get `cancelled`.
541
+ * - If the agent is slow/hung, force-completes **that session's** pending
542
+ * prompt after {@link CANCEL_FORCE_MS} with `stopReason: "cancelled"`.
543
+ * - Never kills the shared agent process (that would stop every multiplexed
544
+ * chat and look like "the bot died").
545
+ */
503
546
  async cancel(sessionId: string): Promise<void> {
547
+ try {
548
+ this.onSessionCancel?.(sessionId);
549
+ } catch (e) {
550
+ log.debug("onSessionCancel failed:", (e as Error).message);
551
+ }
504
552
  try {
505
553
  this.transport?.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
506
554
  } catch (e) {
507
- log.debug("cancel failed:", (e as Error).message);
555
+ log.debug("cancel notify failed:", (e as Error).message);
508
556
  }
557
+ // Soft cancel only — do not killCurrent/stop. Schedule a session-scoped
558
+ // force-complete so a stuck agent cannot leave this chat busy forever.
559
+ this.scheduleCancelForce(sessionId);
560
+ }
561
+
562
+ private clearCancelForce(sessionId: string): void {
563
+ const t = this.cancelForceTimers.get(sessionId);
564
+ if (t) {
565
+ clearTimeout(t);
566
+ this.cancelForceTimers.delete(sessionId);
567
+ }
568
+ }
569
+
570
+ private scheduleCancelForce(sessionId: string): void {
571
+ this.clearCancelForce(sessionId);
572
+ if (!this.promptReqBySession.has(sessionId)) return;
573
+ const timer = setTimeout(() => {
574
+ this.cancelForceTimers.delete(sessionId);
575
+ this.forceCompleteCancelledPrompt(sessionId);
576
+ }, CANCEL_FORCE_MS);
577
+ // Don't keep the process alive solely for cancel force timers.
578
+ timer.unref?.();
579
+ this.cancelForceTimers.set(sessionId, timer);
580
+ }
581
+
582
+ /**
583
+ * Resolve a still-pending prompt for `sessionId` as cancelled. Other sessions'
584
+ * pending requests are left alone. Safe to call when nothing is pending.
585
+ * Idempotent: if the prompt already settled (agent responded, idle timeout,
586
+ * failAllPending), returns false without double-settling.
587
+ */
588
+ forceCompleteCancelledPrompt(sessionId: string): boolean {
589
+ const id = this.promptReqBySession.get(sessionId);
590
+ if (id === undefined) return false;
591
+ const p = this.pending.get(id);
592
+ if (!p || p.method !== "session/prompt" || p.sessionId !== sessionId) return false;
593
+ log.info(`force-completing cancelled prompt for session ${sessionId.slice(0, 8)} (agent slow or ignored cancel)`);
594
+ p.cleanup();
595
+ // settleResolve deletes pending + finishPrompt (single-settlement).
596
+ p.resolve({ stopReason: "cancelled" } satisfies PromptResult);
597
+ return true;
509
598
  }
510
599
 
511
600
  async setModel(sessionId: string, modelId: string): Promise<void> {
@@ -644,6 +733,8 @@ export class GrokClient extends EventEmitter {
644
733
  if (msg.id !== undefined && msg.id !== null && this.pending.has(msg.id) && msg.method === undefined) {
645
734
  const p = this.pending.get(msg.id)!;
646
735
  p.cleanup();
736
+ // Prompt settleResolve/settleReject also delete pending; generic request
737
+ // pending still needs delete here. Double-delete is a no-op on Map.
647
738
  this.pending.delete(msg.id);
648
739
  if (msg.error) p.reject(this.toGrokError(msg.error, p.method));
649
740
  else p.resolve(msg.result);
@@ -781,12 +872,17 @@ export class GrokClient extends EventEmitter {
781
872
  }
782
873
 
783
874
  private failAllPending(err: Error): void {
784
- for (const [, p] of this.pending) {
875
+ for (const t of this.cancelForceTimers.values()) clearTimeout(t);
876
+ this.cancelForceTimers.clear();
877
+ // Snapshot first: prompt settleReject deletes from pending while iterating.
878
+ const pending = [...this.pending.values()];
879
+ this.pending.clear();
880
+ for (const p of pending) {
785
881
  p.cleanup();
786
882
  p.reject(err);
787
883
  }
788
- this.pending.clear();
789
884
  this.running.clear();
885
+ this.promptReqBySession.clear();
790
886
  }
791
887
 
792
888
  private visibleText(content: ContentBlock[]): string {
@@ -810,20 +906,26 @@ export class GrokClient extends EventEmitter {
810
906
  const marker = "User's new message:\n";
811
907
  const mi = t.lastIndexOf(marker);
812
908
  if (mi !== -1) t = t.slice(mi + marker.length);
813
- // Strip auto-complexity steering (and legacy forced-complex wrapper).
814
- const taskMarker = "User task:";
815
- const ti = t.lastIndexOf(taskMarker);
816
- if (
817
- ti !== -1 &&
818
- (/^COMPLEXITY \(decide yourself/i.test(t) || /^TASK COMPLEXITY:/i.test(t))
909
+ // Prefer "User task (continued):" before plain "User task:" (continued
910
+ // contains that substring — lastIndexOf would leave "(continued):…").
911
+ const cont = "User task (continued):";
912
+ const ci = t.lastIndexOf(cont);
913
+ if (ci !== -1) {
914
+ t = t.slice(ci + cont.length);
915
+ } else if (
916
+ /^COMPLEXITY \(decide yourself/i.test(t) ||
917
+ /^TASK COMPLEXITY:/i.test(t)
819
918
  ) {
820
- t = t.slice(ti + taskMarker.length);
919
+ const taskMarker = "User task:";
920
+ const ti = t.indexOf(taskMarker);
921
+ if (ti !== -1) t = t.slice(ti + taskMarker.length);
821
922
  }
822
923
  // Never persist quiet meta-prompts as a user message title.
823
924
  if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
824
925
  if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
825
926
  if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
826
927
  if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
928
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t.trim())) t = "";
827
929
  t = t.replace(/^\([^\n)]*\)\s*\n+/, "");
828
930
  return t.trim();
829
931
  }