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.
- package/.env.example +64 -2
- package/CHANGELOG.md +156 -1
- package/README.md +58 -15
- package/docs/GROUP.md +225 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +1 -1
- package/src/app/accounts.ts +84 -0
- package/src/app/instance-lock.ts +6 -0
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +30 -2
- package/src/app/updater.ts +38 -6
- package/src/app/usage.ts +204 -7
- package/src/bot/account-rotator.ts +10 -0
- package/src/bot/auth.ts +96 -15
- package/src/bot/bot.ts +154 -11
- package/src/bot/chat-controller.ts +82 -13
- package/src/bot/commands.ts +69 -27
- package/src/bot/complexity-gate.ts +69 -0
- package/src/bot/deps.ts +22 -0
- package/src/bot/group-memory.ts +159 -0
- package/src/bot/handlers/accounts.ts +58 -1
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +207 -0
- package/src/bot/handlers/import-session.ts +290 -0
- package/src/bot/handlers/menu.ts +102 -61
- package/src/bot/handlers/message.ts +102 -21
- package/src/bot/handlers/photo.ts +123 -16
- package/src/bot/handlers/running.ts +172 -16
- package/src/bot/handlers/session-card.ts +20 -0
- package/src/bot/handlers/sessions.ts +76 -15
- package/src/bot/handlers/usage.ts +118 -16
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +8 -5
- package/src/bot/menu/ephemeral.ts +13 -3
- package/src/bot/menu/keyboard.ts +54 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +25 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +300 -0
- package/src/bot/prompt-content.ts +7 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +94 -0
- package/src/bot/session-fork.ts +11 -0
- package/src/bot/session-runtime.ts +1254 -83
- package/src/bot/suggestions.ts +489 -0
- package/src/bot/telegram-actions.ts +440 -0
- package/src/bot/telegram-bots.ts +495 -0
- package/src/bot/telegram-io.ts +94 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +242 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +651 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +16 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +214 -37
- package/src/grok/plan-approval.ts +72 -0
- package/src/grok/session-log.ts +16 -0
- package/src/grok/types.ts +21 -2
- package/src/import/build-import.ts +132 -0
- package/src/import/history-readers.ts +681 -0
- package/src/import/list-running.ts +100 -0
- package/src/import/sources.ts +78 -0
- package/src/index.ts +315 -30
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/diff.ts +11 -2
- package/src/render/file-summary.ts +31 -1
- package/src/render/hashtags.ts +5 -1
- package/src/render/markdown.ts +293 -35
- package/src/render/plan.ts +127 -0
- package/src/render/session-comment.ts +318 -0
- package/src/render/telegram-bridge.ts +360 -0
- package/src/render/tool-call-detail.ts +400 -19
- package/src/render/tool-call-merge.ts +115 -0
- package/src/render/tool-call.ts +444 -162
- package/src/render/truncate.ts +85 -0
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +30 -6
- package/src/sessions/history.ts +98 -0
- package/src/sessions/process.ts +7 -0
- package/src/sessions/store.ts +3 -0
- package/src/sessions/types.ts +5 -0
- package/src/stream/streamer.ts +90 -15
- 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
|
+
}
|
package/src/grok/client.ts
CHANGED
|
@@ -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
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
466
|
-
|
|
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
|
-
|
|
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,
|
|
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"
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
|
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
|
}
|