grok-telegram-bot 2.4.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +38 -2
- package/CHANGELOG.md +190 -1
- package/README.md +60 -15
- package/docs/GROUP.md +260 -0
- package/docs/INSTALL.md +3 -0
- package/package.json +4 -4
- package/src/app/lifetime-flag.ts +20 -0
- package/src/app/settings-store.ts +47 -8
- package/src/app/types.ts +38 -1
- package/src/app/updater.ts +24 -3
- package/src/bot/auth.ts +100 -15
- package/src/bot/bot.ts +193 -17
- package/src/bot/chat-controller.ts +181 -18
- package/src/bot/commands.ts +69 -29
- package/src/bot/deps.ts +3 -0
- package/src/bot/group-memory.ts +339 -0
- package/src/bot/handlers/accounts.ts +7 -0
- package/src/bot/handlers/control.ts +85 -32
- package/src/bot/handlers/document.ts +31 -4
- package/src/bot/handlers/forum.ts +217 -0
- package/src/bot/handlers/menu.ts +86 -24
- package/src/bot/handlers/message.ts +247 -27
- package/src/bot/handlers/photo.ts +126 -16
- package/src/bot/handlers/running.ts +150 -24
- package/src/bot/handlers/session-card.ts +13 -5
- package/src/bot/handlers/sessions.ts +68 -18
- package/src/bot/handlers/voice.ts +52 -7
- package/src/bot/image-return.ts +11 -5
- package/src/bot/manager-context.ts +208 -0
- package/src/bot/manager-jobs.ts +142 -0
- package/src/bot/menu/ephemeral.ts +16 -3
- package/src/bot/menu/keyboard.ts +53 -14
- package/src/bot/menu/refresh.ts +3 -1
- package/src/bot/menu/status-panel.ts +12 -6
- package/src/bot/permission-service.ts +19 -0
- package/src/bot/prompt-anchor.ts +299 -0
- package/src/bot/prompt-content.ts +8 -0
- package/src/bot/registry.ts +94 -1
- package/src/bot/scope.ts +95 -0
- package/src/bot/session-runtime.ts +1280 -183
- package/src/bot/suggestions.ts +91 -31
- package/src/bot/telegram-actions.ts +1130 -0
- package/src/bot/telegram-bots.ts +496 -0
- package/src/bot/telegram-io.ts +97 -10
- package/src/cli.ts +2 -0
- package/src/config.ts +201 -2
- package/src/forum/bind-path.ts +146 -0
- package/src/forum/manager.ts +652 -0
- package/src/forum/project-icon.ts +142 -0
- package/src/forum/thread.ts +49 -0
- package/src/forum/topic-store.ts +114 -0
- package/src/forum/types.ts +29 -0
- package/src/grok/client.ts +130 -28
- package/src/index.ts +205 -75
- package/src/projects/manager.ts +16 -3
- package/src/render/chunk.ts +17 -10
- package/src/render/hashtags.ts +5 -1
- package/src/render/manager-directive.ts +137 -0
- package/src/render/session-comment.ts +74 -7
- package/src/render/telegram-bridge.ts +464 -0
- package/src/render/tool-call.ts +56 -37
- package/src/service/platform.ts +44 -7
- package/src/service/windows.ts +16 -4
- package/src/sessions/history.ts +68 -9
- package/src/sessions/process.ts +7 -0
- package/src/sessions/types.ts +2 -2
- package/src/stream/streamer.ts +62 -15
- package/scripts/analyze-jsonl.ts +0 -33
- package/scripts/delayed-restart.ps1 +0 -29
- package/scripts/probe-exit-response-shape.py +0 -77
- package/scripts/probe-plan-exit.py +0 -60
- package/scripts/probe-plan-exit2.py +0 -48
- package/scripts/probe-plan-fields.py +0 -41
- package/scripts/probe-plan-fields2.py +0 -58
- package/scripts/probe-plan-response-path.py +0 -48
- package/scripts/sample-claude-tooluse.ts +0 -21
- package/scripts/sample-kiro-events.ts +0 -31
- package/scripts/smoke-exit-plan.ts +0 -274
- package/scripts/smoke-exit-shapes.ts +0 -252
- package/scripts/smoke-import.mjs +0 -82
- package/scripts/smoke-import.ts +0 -73
|
@@ -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
|
+
}
|
package/src/grok/client.ts
CHANGED
|
@@ -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:
|
|
473
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
//
|
|
814
|
-
|
|
815
|
-
const
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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
|
}
|