grok-telegram-bot 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -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,16 @@
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
+ }
@@ -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
+ }
@@ -19,19 +19,26 @@ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
19
19
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
20
20
  import { SessionLog } from "./session-log.js";
21
21
  import { JsonRpcTransport } from "./transport.js";
22
- import type {
23
- ContentBlock,
24
- InitializeResult,
25
- JsonRpcMessage,
26
- PendingStage,
27
- PermissionOutcome,
28
- PromptResult,
29
- RequestPermissionParams,
30
- SessionNotificationParams,
31
- SessionUpdate,
32
- SubagentInfo,
33
- SubagentListUpdate,
22
+ import {
23
+ contentText,
24
+ type ContentBlock,
25
+ type InitializeResult,
26
+ type JsonRpcMessage,
27
+ type PendingStage,
28
+ type PermissionOutcome,
29
+ type PromptResult,
30
+ type RequestPermissionParams,
31
+ type SessionNotificationParams,
32
+ type SessionUpdate,
33
+ type SubagentInfo,
34
+ type SubagentListUpdate,
34
35
  } from "./types.js";
36
+ import {
37
+ autoApproveExitPlanMode,
38
+ autoSkipAskUserQuestion,
39
+ isAskUserQuestionMethod,
40
+ isPlanExitMethod,
41
+ } from "./plan-approval.js";
35
42
 
36
43
  const log = createLogger("grok:client");
37
44
 
@@ -218,8 +225,14 @@ interface Pending {
218
225
  reject: (e: Error) => void;
219
226
  cleanup: () => void;
220
227
  method: string;
228
+ /** Set for in-flight `session/prompt` so cancel can target one session only. */
229
+ sessionId?: string;
221
230
  }
222
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
+
223
236
  export declare interface GrokClient {
224
237
  on(e: "session-update", l: (sessionId: string, update: SessionUpdate) => void): this;
225
238
  on(e: "notification", l: (method: string, params: unknown) => void): this;
@@ -251,6 +264,10 @@ export class GrokClient extends EventEmitter {
251
264
  private readonly cwd = new Map<string, string>();
252
265
  /** Sessions with an in-flight prompt (drives "active"). */
253
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>();
254
271
  /** Accumulated assistant text per in-flight turn (flushed to the log on end). */
255
272
  private readonly assistantBuf = new Map<string, string>();
256
273
  private authMethodId?: string;
@@ -265,6 +282,12 @@ export class GrokClient extends EventEmitter {
265
282
  private subagents: SubagentInfo[] = [];
266
283
  private pendingStages: PendingStage[] = [];
267
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;
268
291
 
269
292
  constructor(private readonly opts: GrokClientOptions) {
270
293
  super();
@@ -433,8 +456,26 @@ export class GrokClient extends EventEmitter {
433
456
  return new Promise<PromptResult>((resolve, reject) => {
434
457
  const id = this.nextId++;
435
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
+ };
436
476
  this.lastActivity.set(sessionId, start);
437
477
  this.running.add(sessionId);
478
+ this.promptReqBySession.set(sessionId, id);
438
479
  if (this.proc?.pid) this.slog.lock(sessionId, this.proc.pid);
439
480
  const userText = this.cleanUserText(content);
440
481
  this.slog.logUser(sessionId, userText);
@@ -444,48 +485,47 @@ export class GrokClient extends EventEmitter {
444
485
  if (title) this.slog.update(sessionId, { title });
445
486
  }
446
487
  const watch = setInterval(() => {
488
+ if (settled) {
489
+ clearInterval(watch);
490
+ return;
491
+ }
447
492
  const last = Math.max(this.lastActivity.get(sessionId) ?? start, this.lastActivityAny);
448
493
  const idle = Date.now() - last;
449
494
  const total = Date.now() - start;
450
495
  if (total > this.promptMaxMs) {
451
- this.pending.delete(id);
452
- this.finishPrompt(sessionId, id);
453
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`));
454
500
  void this.cancel(sessionId);
455
- reject(new Error(`Prompt exceeded the ${Math.round(this.promptMaxMs / 60_000)}min cap`));
456
501
  } else if (idle > this.promptIdleMs) {
457
- this.pending.delete(id);
458
- this.finishPrompt(sessionId, id);
459
502
  clearInterval(watch);
503
+ settleReject(new Error(`No agent activity for ${Math.round(idle / 1000)}s — giving up`));
460
504
  void this.cancel(sessionId);
461
- reject(new Error(`No agent activity for ${Math.round(idle / 1000)}s — giving up`));
462
505
  }
463
506
  }, 15_000);
464
507
  this.pending.set(id, {
465
- resolve: (v) => {
466
- this.finishPrompt(sessionId, id);
467
- resolve(v as PromptResult);
468
- },
469
- reject: (e) => {
470
- this.finishPrompt(sessionId, id);
471
- reject(e);
472
- },
508
+ resolve: settleResolve,
509
+ reject: settleReject,
473
510
  cleanup: () => clearInterval(watch),
474
511
  method: "session/prompt",
512
+ sessionId,
475
513
  });
476
514
  try {
477
515
  this.transport!.send({ jsonrpc: "2.0", id, method: "session/prompt", params: { sessionId, prompt: content } });
478
516
  } catch (e) {
479
517
  clearInterval(watch);
480
- this.pending.delete(id);
481
- this.finishPrompt(sessionId, id);
482
- reject(e as Error);
518
+ settleReject(e as Error);
483
519
  }
484
520
  });
485
521
  }
486
522
 
487
523
  /** Clear the running/lock state for a finished turn and flush its transcript. */
488
- 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
+ }
489
529
  this.running.delete(sessionId);
490
530
  this.slog.unlock(sessionId);
491
531
  const buf = this.assistantBuf.get(sessionId);
@@ -493,12 +533,68 @@ export class GrokClient extends EventEmitter {
493
533
  this.assistantBuf.delete(sessionId);
494
534
  }
495
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
+ */
496
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
+ }
497
552
  try {
498
553
  this.transport?.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
499
554
  } catch (e) {
500
- log.debug("cancel failed:", (e as Error).message);
555
+ log.debug("cancel notify failed:", (e as Error).message);
501
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;
502
598
  }
503
599
 
504
600
  async setModel(sessionId: string, modelId: string): Promise<void> {
@@ -512,6 +608,16 @@ export class GrokClient extends EventEmitter {
512
608
  this.currentModeId = modeId;
513
609
  }
514
610
 
611
+ /** Persisted Running/Sessions card comment (current step or chat summary). */
612
+ sessionComment(sessionId: string | undefined): string | undefined {
613
+ if (!sessionId) return undefined;
614
+ return this.slog.commentFor(sessionId);
615
+ }
616
+
617
+ setSessionComment(sessionId: string, comment: string): void {
618
+ this.slog.setComment(sessionId, comment);
619
+ }
620
+
515
621
  async executeCommand(sessionId: string, command: string): Promise<unknown> {
516
622
  return this.request("_grok.dev/commands/execute", { sessionId, command });
517
623
  }
@@ -627,6 +733,8 @@ export class GrokClient extends EventEmitter {
627
733
  if (msg.id !== undefined && msg.id !== null && this.pending.has(msg.id) && msg.method === undefined) {
628
734
  const p = this.pending.get(msg.id)!;
629
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.
630
738
  this.pending.delete(msg.id);
631
739
  if (msg.error) p.reject(this.toGrokError(msg.error, p.method));
632
740
  else p.resolve(msg.result);
@@ -654,8 +762,34 @@ export class GrokClient extends EventEmitter {
654
762
  // No handler: auto-approve, preferring session-scope / always options.
655
763
  const opts = (params.options as Array<{ optionId: string; name?: string; kind?: string }>) ?? [];
656
764
  result = pickAllowOption(opts);
765
+ } else if (isPlanExitMethod(method)) {
766
+ // Live method name is `_x.ai/exit_plan_mode` (leading underscore).
767
+ // Grok intercepts exit_plan_mode and reverse-requests the client to
768
+ // show a plan-approval UI. Method-not-found is reported as
769
+ // "client disconnected" and plan mode stays Active forever.
770
+ const planSnippet =
771
+ (typeof params.planContent === "string" && params.planContent) ||
772
+ (typeof params.plan_content === "string" && params.plan_content) ||
773
+ (typeof params.plan_file_path === "string" && params.plan_file_path) ||
774
+ "";
775
+ const keys = Object.keys(params || {}).slice(0, 20).join(",");
776
+ log.info(
777
+ `auto-approving plan exit via ${method}` +
778
+ (params.sessionId ? ` session=${String(params.sessionId).slice(0, 8)}` : "") +
779
+ (params.toolCallId ? ` tool=${String(params.toolCallId).slice(0, 24)}` : "") +
780
+ (planSnippet ? ` plan=${planSnippet.replace(/\s+/g, " ").slice(0, 80)}` : "") +
781
+ (keys ? ` keys=[${keys}]` : ""),
782
+ );
783
+ result = autoApproveExitPlanMode(params);
784
+ } else if (isAskUserQuestionMethod(method)) {
785
+ // No TUI question form: skip so the agent continues (prefer later Telegram UI).
786
+ log.info(`auto-skipping ${method} (no interactive question UI in Telegram bridge)`);
787
+ result = autoSkipAskUserQuestion(params);
657
788
  } else {
658
789
  // We advertise no fs/terminal capabilities, so the agent shouldn't ask.
790
+ // Log at warn — unknown reverse methods used to silently break plan exit
791
+ // when we only matched `x.ai/…` and Grok sent `_x.ai/…`.
792
+ log.warn(`unsupported client reverse-request: ${method} keys=[${Object.keys(params || {}).join(",")}]`);
659
793
  throw new GrokError(`unsupported client method: ${method}`, -32601);
660
794
  }
661
795
  this.transport?.send({ jsonrpc: "2.0", id, result });
@@ -701,10 +835,28 @@ export class GrokClient extends EventEmitter {
701
835
 
702
836
  /** Accumulate assistant text and log tool calls to the session's jsonl. */
703
837
  private recordUpdate(sessionId: string, u: SessionUpdate): void {
704
- if (u.sessionUpdate === "agent_message_chunk" && typeof u.content?.text === "string") {
705
- this.assistantBuf.set(sessionId, (this.assistantBuf.get(sessionId) ?? "") + u.content.text);
706
- } else if (u.sessionUpdate === "tool_call" && (u.title || u.kind)) {
707
- this.slog.logTool(sessionId, u.title || u.kind || "tool", "");
838
+ if (u.sessionUpdate === "agent_message_chunk") {
839
+ const t = contentText(u.content);
840
+ if (t) this.assistantBuf.set(sessionId, (this.assistantBuf.get(sessionId) ?? "") + t);
841
+ } else if (u.sessionUpdate === "tool_call" || u.sessionUpdate === "tool_call_update") {
842
+ // Prefer stable name over generic title ("Tool call").
843
+ const name =
844
+ (typeof u.name === "string" && u.name) ||
845
+ (typeof u.toolName === "string" && u.toolName) ||
846
+ (typeof u.title === "string" && u.title && !/^tool[_ ]?call$/i.test(u.title) ? u.title : "") ||
847
+ u.kind ||
848
+ "tool";
849
+ const raw = (u.rawInput || {}) as Record<string, unknown>;
850
+ const detail =
851
+ (typeof raw.path === "string" && raw.path) ||
852
+ (typeof raw.target_file === "string" && raw.target_file) ||
853
+ (typeof raw.command === "string" && raw.command) ||
854
+ (typeof raw.pattern === "string" && raw.pattern) ||
855
+ (Array.isArray(u.locations) && u.locations[0]?.path) ||
856
+ "";
857
+ if (u.sessionUpdate === "tool_call" || detail) {
858
+ this.slog.logTool(sessionId, String(name), detail ? String(detail).slice(0, 200) : "");
859
+ }
708
860
  }
709
861
  // Derive a context-usage %/token count if the update carries usage info.
710
862
  const usage = (u as { usage?: { totalTokens?: number } }).usage;
@@ -720,12 +872,17 @@ export class GrokClient extends EventEmitter {
720
872
  }
721
873
 
722
874
  private failAllPending(err: Error): void {
723
- 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) {
724
881
  p.cleanup();
725
882
  p.reject(err);
726
883
  }
727
- this.pending.clear();
728
884
  this.running.clear();
885
+ this.promptReqBySession.clear();
729
886
  }
730
887
 
731
888
  private visibleText(content: ContentBlock[]): string {
@@ -749,6 +906,26 @@ export class GrokClient extends EventEmitter {
749
906
  const marker = "User's new message:\n";
750
907
  const mi = t.lastIndexOf(marker);
751
908
  if (mi !== -1) t = t.slice(mi + marker.length);
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)
918
+ ) {
919
+ const taskMarker = "User task:";
920
+ const ti = t.indexOf(taskMarker);
921
+ if (ti !== -1) t = t.slice(ti + taskMarker.length);
922
+ }
923
+ // Never persist quiet meta-prompts as a user message title.
924
+ if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
925
+ if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
926
+ if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
927
+ if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
928
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t.trim())) t = "";
752
929
  t = t.replace(/^\([^\n)]*\)\s*\n+/, "");
753
930
  return t.trim();
754
931
  }