grok-telegram-bot 2.0.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 +135 -0
- package/CHANGELOG.md +598 -0
- package/LICENSE +21 -0
- package/README.md +644 -0
- package/bin/grok-tg.mjs +21 -0
- package/docs/INSTALL.md +153 -0
- package/docs/UPGRADE.md +253 -0
- package/docs/ops/RELEASE_CHECKLIST.md +39 -0
- package/package.json +74 -0
- package/scripts/setup.mjs +116 -0
- package/src/agents/catalog.ts +58 -0
- package/src/app/accounts.ts +162 -0
- package/src/app/auth-service.ts +136 -0
- package/src/app/grok-credentials.ts +103 -0
- package/src/app/instance-lock.ts +139 -0
- package/src/app/json-store.ts +54 -0
- package/src/app/reasoning.ts +30 -0
- package/src/app/settings-store.ts +38 -0
- package/src/app/stt.ts +53 -0
- package/src/app/types.ts +56 -0
- package/src/app/updater.ts +234 -0
- package/src/app/usage.ts +38 -0
- package/src/app/version.ts +41 -0
- package/src/bot/account-rotator.ts +52 -0
- package/src/bot/auth.ts +38 -0
- package/src/bot/bot.ts +225 -0
- package/src/bot/chat-controller.ts +317 -0
- package/src/bot/commands.ts +52 -0
- package/src/bot/deps.ts +67 -0
- package/src/bot/file-ingest.ts +190 -0
- package/src/bot/handlers/accounts.ts +220 -0
- package/src/bot/handlers/auth.ts +64 -0
- package/src/bot/handlers/control.ts +103 -0
- package/src/bot/handlers/document.ts +112 -0
- package/src/bot/handlers/history.ts +63 -0
- package/src/bot/handlers/kill.ts +54 -0
- package/src/bot/handlers/mcp.ts +206 -0
- package/src/bot/handlers/menu.ts +220 -0
- package/src/bot/handlers/message.ts +103 -0
- package/src/bot/handlers/photo.ts +123 -0
- package/src/bot/handlers/projects.ts +183 -0
- package/src/bot/handlers/running.ts +181 -0
- package/src/bot/handlers/session-card.ts +81 -0
- package/src/bot/handlers/session-kill.ts +95 -0
- package/src/bot/handlers/sessions.ts +148 -0
- package/src/bot/handlers/system.ts +51 -0
- package/src/bot/handlers/tasks.ts +224 -0
- package/src/bot/handlers/usage.ts +38 -0
- package/src/bot/handlers/voice.ts +55 -0
- package/src/bot/image-return.ts +69 -0
- package/src/bot/menu/ephemeral.ts +117 -0
- package/src/bot/menu/keyboard.ts +49 -0
- package/src/bot/menu/refresh.ts +13 -0
- package/src/bot/menu/status-panel.ts +173 -0
- package/src/bot/permission-service.ts +149 -0
- package/src/bot/prompt-content.ts +64 -0
- package/src/bot/prompt-retry.ts +70 -0
- package/src/bot/reauth-controller.ts +297 -0
- package/src/bot/registry.ts +186 -0
- package/src/bot/reply-context.ts +77 -0
- package/src/bot/session-fork.ts +35 -0
- package/src/bot/session-runtime.ts +1048 -0
- package/src/bot/telegram-io.ts +109 -0
- package/src/bot/typing.ts +35 -0
- package/src/bot/wizard/task-wizard.ts +214 -0
- package/src/cli.ts +126 -0
- package/src/config.ts +248 -0
- package/src/grok/client.ts +617 -0
- package/src/grok/models.ts +50 -0
- package/src/grok/session-log.ts +148 -0
- package/src/grok/transport.ts +51 -0
- package/src/grok/types.ts +136 -0
- package/src/index.ts +84 -0
- package/src/logger.ts +78 -0
- package/src/mcp/config.ts +120 -0
- package/src/mcp/probe.ts +218 -0
- package/src/mcp/types.ts +68 -0
- package/src/projects/manager.ts +99 -0
- package/src/render/chunk.ts +57 -0
- package/src/render/diff.ts +48 -0
- package/src/render/escape.ts +22 -0
- package/src/render/file-summary.ts +111 -0
- package/src/render/hashtags.ts +34 -0
- package/src/render/markdown.ts +130 -0
- package/src/render/progress-estimate.ts +63 -0
- package/src/render/progress.ts +80 -0
- package/src/render/subagent.ts +75 -0
- package/src/render/tool-call.ts +196 -0
- package/src/service/index.ts +24 -0
- package/src/service/linux.ts +85 -0
- package/src/service/macos.ts +101 -0
- package/src/service/platform.ts +64 -0
- package/src/service/types.ts +36 -0
- package/src/service/windows.ts +198 -0
- package/src/sessions/history.ts +225 -0
- package/src/sessions/process.ts +30 -0
- package/src/sessions/store.ts +133 -0
- package/src/sessions/tail.ts +86 -0
- package/src/sessions/types.ts +26 -0
- package/src/stream/streamer.ts +261 -0
- package/src/tasks/runner.ts +82 -0
- package/src/tasks/schedule.ts +142 -0
- package/src/tasks/scheduler.ts +53 -0
- package/src/tasks/store.ts +80 -0
- package/src/tasks/types.ts +33 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ephemeral message tracker — keeps the chat history clean.
|
|
3
|
+
*
|
|
4
|
+
* Navigation surfaces (the inline menu, session/project cards, pickers, status
|
|
5
|
+
* snapshots, submenus) are *transient*: they're tracked per chat and removed
|
|
6
|
+
* when a new surface opens or an action resolves. Persistent messages
|
|
7
|
+
* (🔀 Switched / ✨ New session boundary markers, agent output, Done summaries,
|
|
8
|
+
* the pinned status panel) are simply never tracked, so they survive clear().
|
|
9
|
+
*
|
|
10
|
+
* Tracked ids are persisted to disk so a restart (manual, crash, or
|
|
11
|
+
* auto-update) doesn't orphan the last surface — it's cleaned on startup and by
|
|
12
|
+
* the next surface that opens.
|
|
13
|
+
*/
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { type Api, type Context, GrammyError } from "grammy";
|
|
16
|
+
import { JsonStore } from "../../app/json-store.js";
|
|
17
|
+
|
|
18
|
+
type TrackMap = Record<string, number[]>;
|
|
19
|
+
|
|
20
|
+
export class Ephemeral {
|
|
21
|
+
private readonly store: JsonStore<TrackMap>;
|
|
22
|
+
/** Per-chat promise chain so open/clear/reply never interleave for one chat
|
|
23
|
+
* (e.g. startup cleanupAll racing the first surface the user opens). */
|
|
24
|
+
private readonly locks = new Map<number, Promise<unknown>>();
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly api: Api,
|
|
28
|
+
dataDir: string,
|
|
29
|
+
) {
|
|
30
|
+
this.store = new JsonStore<TrackMap>(join(dataDir, "ephemeral.json"), {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Run `fn` after any in-flight ephemeral op for this chat completes. */
|
|
34
|
+
private serialize<T>(chatId: number, fn: () => Promise<T>): Promise<T> {
|
|
35
|
+
const prev = this.locks.get(chatId) ?? Promise.resolve();
|
|
36
|
+
const run = prev.then(fn, fn);
|
|
37
|
+
this.locks.set(chatId, run.then(noop, noop));
|
|
38
|
+
return run;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Track a bot message id for later cleanup. */
|
|
42
|
+
remember(chatId: number, messageId: number | undefined): void {
|
|
43
|
+
if (!messageId) return;
|
|
44
|
+
this.store.update((m) => {
|
|
45
|
+
const k = String(chatId);
|
|
46
|
+
(m[k] ??= []).push(messageId);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Delete every tracked transient message for a chat (best-effort). */
|
|
51
|
+
async clear(chatId: number | undefined): Promise<void> {
|
|
52
|
+
if (chatId === undefined) return;
|
|
53
|
+
await this.serialize(chatId, () => this.doClear(chatId));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private async doClear(chatId: number): Promise<void> {
|
|
57
|
+
const k = String(chatId);
|
|
58
|
+
const ids = (this.store.get()[k] ?? []).slice();
|
|
59
|
+
if (ids.length === 0) return;
|
|
60
|
+
// A Telegram rejection (message gone / too old / not found) is final, so we
|
|
61
|
+
// forget that id. A transient/network failure is KEPT for the next sweep so
|
|
62
|
+
// a tracked card never becomes a permanent "ghost" (the duplicate-cards bug).
|
|
63
|
+
const keep: number[] = [];
|
|
64
|
+
await Promise.all(
|
|
65
|
+
ids.map(async (id) => {
|
|
66
|
+
try {
|
|
67
|
+
await this.api.deleteMessage(chatId, id);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (!(err instanceof GrammyError)) keep.push(id);
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
this.store.update((m) => {
|
|
74
|
+
if (keep.length > 0) m[k] = keep;
|
|
75
|
+
else delete m[k];
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** On startup, delete any surface left over from before a restart. */
|
|
80
|
+
async cleanupAll(): Promise<void> {
|
|
81
|
+
for (const k of Object.keys(this.store.get())) {
|
|
82
|
+
await this.clear(Number(k));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Open a fresh navigation surface: clear whatever transient surface was up.
|
|
88
|
+
* Call at the start of every menu/card/picker handler.
|
|
89
|
+
*/
|
|
90
|
+
async open(ctx: Context): Promise<void> {
|
|
91
|
+
await this.clear(ctx.chat?.id);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Send a transient reply (tracked) — use for menus / cards / pickers. */
|
|
95
|
+
async reply(ctx: Context, text: string, extra: Record<string, unknown> = {}): Promise<number | undefined> {
|
|
96
|
+
const chatId = ctx.chat?.id;
|
|
97
|
+
if (chatId === undefined) return undefined;
|
|
98
|
+
return this.serialize(chatId, async () => {
|
|
99
|
+
try {
|
|
100
|
+
const msg = await ctx.reply(text, extra);
|
|
101
|
+
this.remember(chatId, msg.message_id);
|
|
102
|
+
return msg.message_id;
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Delete just one tracked message (e.g. closing a single card). */
|
|
110
|
+
async drop(ctx: Context): Promise<void> {
|
|
111
|
+
await ctx.deleteMessage().catch(() => {});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function noop(): void {
|
|
116
|
+
/* swallow chain errors so serialize() keeps flowing */
|
|
117
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu surfaces:
|
|
3
|
+
* - a tiny PERSISTENT bar (☰ Menu · 🧭 Running · ⏹ Stop) — minimal footprint;
|
|
4
|
+
* - a full, organized INLINE menu opened on demand (and hideable).
|
|
5
|
+
* Live state (project/agent/model/reasoning/context) lives in the pinned panel,
|
|
6
|
+
* so the bar stays clean.
|
|
7
|
+
*/
|
|
8
|
+
import { InlineKeyboard, Keyboard } from "grammy";
|
|
9
|
+
|
|
10
|
+
export const MENU_BTN = "\u2630 Menu"; // ☰
|
|
11
|
+
export const RUNNING_BTN = "\u{1F9ED} Running";
|
|
12
|
+
export const STOP_BTN = "\u23F9 Stop";
|
|
13
|
+
export const BAR_LABELS = [MENU_BTN, RUNNING_BTN, STOP_BTN];
|
|
14
|
+
|
|
15
|
+
/** The always-visible compact bar. */
|
|
16
|
+
export function compactKeyboard(): Keyboard {
|
|
17
|
+
return new Keyboard().text(MENU_BTN).text(RUNNING_BTN).text(STOP_BTN).resized().persistent();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The full, grouped inline menu (opened via ☰ Menu or /menu). */
|
|
21
|
+
export function mainMenuInline(state: { agent: string; model: string; reasoning: string }): InlineKeyboard {
|
|
22
|
+
const t = (s: string, n: number): string => (s.length > n ? s.slice(0, n - 1) + "\u2026" : s);
|
|
23
|
+
return new InlineKeyboard()
|
|
24
|
+
.text("\u{1F4C1} Project", "m:project")
|
|
25
|
+
.text("\u{1F195} New", "m:new")
|
|
26
|
+
.row()
|
|
27
|
+
.text("\u{1F9ED} Running", "m:running")
|
|
28
|
+
.text("\u{1F5C2} Sessions", "m:sessions")
|
|
29
|
+
.row()
|
|
30
|
+
.text(`\u{1F916} Agent \u00B7 ${t(state.agent, 24)}`, "m:agent")
|
|
31
|
+
.row()
|
|
32
|
+
.text(`\u{1F9E9} Model \u00B7 ${t(state.model, 24)}`, "m:model")
|
|
33
|
+
.row()
|
|
34
|
+
.text(`\u{1F9E0} Reasoning \u00B7 ${t(state.reasoning, 24)}`, "m:reasoning")
|
|
35
|
+
.row()
|
|
36
|
+
.text("\u2705 Tasks", "m:tasks")
|
|
37
|
+
.text("\u{1F4CA} Status", "m:status")
|
|
38
|
+
.text("\u{1F4B3} Usage", "m:usage")
|
|
39
|
+
.row()
|
|
40
|
+
.text("\u{1F465} Accounts", "m:accounts")
|
|
41
|
+
.row()
|
|
42
|
+
.text("\u{1F9E9} MCP", "m:mcp")
|
|
43
|
+
.text("\u23F9 Stop", "m:stop")
|
|
44
|
+
.text("\u{1F6D1} Kill all", "m:killall")
|
|
45
|
+
.row()
|
|
46
|
+
.text("\u2328\uFE0F Show bar", "m:showbar")
|
|
47
|
+
.text("\u{1F648} Hide bar", "m:hidebar")
|
|
48
|
+
.text("\u2716 Close", "m:close");
|
|
49
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sends a short message that (re)shows the compact bar and refreshes the pinned
|
|
3
|
+
* status panel (where the live project/agent/model/reasoning state is shown).
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from "grammy";
|
|
6
|
+
import type { BotDeps } from "../deps.js";
|
|
7
|
+
import { compactKeyboard } from "./keyboard.js";
|
|
8
|
+
|
|
9
|
+
export async function refreshMenu(ctx: Context, deps: BotDeps, text: string): Promise<void> {
|
|
10
|
+
const chatId = ctx.chat!.id;
|
|
11
|
+
await ctx.reply(text, { reply_markup: compactKeyboard() });
|
|
12
|
+
await deps.statusPanel.refresh(chatId);
|
|
13
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status panel — a pinned message that always shows the current project,
|
|
3
|
+
* agent, reasoning effort, model, session and activity. Updated whenever the
|
|
4
|
+
* runtime's state changes. The pinned message id is persisted per chat.
|
|
5
|
+
*/
|
|
6
|
+
import { type Api, GrammyError } from "grammy";
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
import { reasoningLabel } from "../../app/reasoning.js";
|
|
9
|
+
import { progressBar } from "../../render/progress.js";
|
|
10
|
+
import type { SettingsStore } from "../../app/settings-store.js";
|
|
11
|
+
import { createLogger } from "../../logger.js";
|
|
12
|
+
import type { RuntimeRegistry } from "../registry.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("status-panel");
|
|
15
|
+
|
|
16
|
+
/** Minimum gap between pinned-panel edits per chat (coalesces bursty updates). */
|
|
17
|
+
const REFRESH_THROTTLE_MS = 1000;
|
|
18
|
+
|
|
19
|
+
export class StatusPanel {
|
|
20
|
+
/** Per-chat coalescing + serialization: only ONE refresh runs at a time per
|
|
21
|
+
* chat, so concurrent state changes (e.g. many subagents updating at once)
|
|
22
|
+
* can't each create a duplicate pinned panel. `again` collapses a burst into
|
|
23
|
+
* a single follow-up run; `lastRun` throttles edits. */
|
|
24
|
+
private readonly busy = new Map<number, boolean>();
|
|
25
|
+
private readonly again = new Map<number, boolean>();
|
|
26
|
+
private readonly lastRun = new Map<number, number>();
|
|
27
|
+
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly api: Api,
|
|
30
|
+
private readonly settings: SettingsStore,
|
|
31
|
+
private readonly registry: RuntimeRegistry,
|
|
32
|
+
) {}
|
|
33
|
+
|
|
34
|
+
/** Build the status text from settings + live runtime state. */
|
|
35
|
+
render(chatId: number): string {
|
|
36
|
+
const s = this.settings.get(chatId);
|
|
37
|
+
const rt = this.registry.get(chatId);
|
|
38
|
+
// Project comes from the live foreground runtime — not the persisted single
|
|
39
|
+
// session — so it always matches the session id shown below, even right
|
|
40
|
+
// after switching between controlled sessions in different projects.
|
|
41
|
+
const project = rt.projectName || (rt.cwd ? basename(rt.cwd) : "(none)");
|
|
42
|
+
const session = rt.sessionId ? rt.sessionId.slice(0, 8) : "none";
|
|
43
|
+
const meta = rt.contextInfo();
|
|
44
|
+
const ctxPct = meta?.contextUsagePercentage;
|
|
45
|
+
const running = this.registry.controller(chatId).count();
|
|
46
|
+
const subagents = this.registry.subagentSummaryForChat(chatId);
|
|
47
|
+
const progress = rt.taskProgress;
|
|
48
|
+
|
|
49
|
+
const SEP = " | "; // pipe delimiter between inline fields
|
|
50
|
+
const lines: string[] = [];
|
|
51
|
+
|
|
52
|
+
// 1) Progress first — only while a turn is live (cleared when it ends), so
|
|
53
|
+
// the collapsed pin preview shows how far along the current task is.
|
|
54
|
+
if (progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(progress)}`);
|
|
55
|
+
|
|
56
|
+
// 2) Activity: state + only the counters that currently apply.
|
|
57
|
+
const activity: string[] = [rt.isBusy ? "\u23F3 Working" : "\u2705 Idle"];
|
|
58
|
+
if (rt.queueLength > 0) activity.push(`\u{1F4E5} ${rt.queueLength} queued`);
|
|
59
|
+
if (running > 1) activity.push(`\u{1F9ED} ${running} sessions`);
|
|
60
|
+
if (rt.isWatching) activity.push("\u{1F4E1} watching");
|
|
61
|
+
if (subagents) activity.push(`\u{1F465} ${subagents}`);
|
|
62
|
+
lines.push(activity.join(SEP));
|
|
63
|
+
|
|
64
|
+
// 3) Where: project | session | context usage.
|
|
65
|
+
const loc = [`\u{1F4C1} ${project}`, `\u{1F9F5} ${session}`];
|
|
66
|
+
if (ctxPct !== undefined) loc.push(`\u{1F4CA} ${ctxPct.toFixed(0)}% context`);
|
|
67
|
+
lines.push(loc.join(SEP));
|
|
68
|
+
|
|
69
|
+
// 4) How: agent | reasoning | model.
|
|
70
|
+
lines.push([`\u{1F916} ${s.agent || "default"}`, `\u{1F9E0} ${reasoningLabel(s.reasoning)}`, `\u{1F9E9} ${s.model || "default"}`].join(SEP));
|
|
71
|
+
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Coalesced, serialized refresh: only ONE update runs per chat at a time, so
|
|
76
|
+
* rapid state changes (many subagents, fast tool calls) can't each create a
|
|
77
|
+
* duplicate pinned panel. Extra requests during a run collapse into a single
|
|
78
|
+
* follow-up run, throttled so we don't hammer Telegram with edits. */
|
|
79
|
+
async refresh(chatId: number): Promise<void> {
|
|
80
|
+
if (this.busy.get(chatId)) {
|
|
81
|
+
this.again.set(chatId, true);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.busy.set(chatId, true);
|
|
85
|
+
try {
|
|
86
|
+
do {
|
|
87
|
+
this.again.set(chatId, false);
|
|
88
|
+
const since = Date.now() - (this.lastRun.get(chatId) ?? 0);
|
|
89
|
+
if (since < REFRESH_THROTTLE_MS) await sleep(REFRESH_THROTTLE_MS - since);
|
|
90
|
+
await this.doRefresh(chatId);
|
|
91
|
+
this.lastRun.set(chatId, Date.now());
|
|
92
|
+
} while (this.again.get(chatId));
|
|
93
|
+
} finally {
|
|
94
|
+
this.busy.set(chatId, false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** One render + send/edit/remove pass. Never spawns a duplicate panel: it only
|
|
99
|
+
* (re)creates when there's no panel yet, or the existing one is truly gone. */
|
|
100
|
+
private async doRefresh(chatId: number): Promise<void> {
|
|
101
|
+
const rt = this.registry.get(chatId);
|
|
102
|
+
const id = this.settings.get(chatId).statusMessageId;
|
|
103
|
+
|
|
104
|
+
// The pinned panel exists only while there's live work — a running turn or a
|
|
105
|
+
// queued follow-up about to run. When the session is idle there's nothing to
|
|
106
|
+
// show, so remove the panel to keep the chat clean. (The on-demand /status
|
|
107
|
+
// still renders full state when explicitly requested.)
|
|
108
|
+
const active = rt.isBusy || rt.queueLength > 0;
|
|
109
|
+
if (!active) {
|
|
110
|
+
if (id) await this.remove(chatId, id);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const text = this.render(chatId);
|
|
115
|
+
if (id) {
|
|
116
|
+
try {
|
|
117
|
+
await this.api.editMessageText(chatId, id, text);
|
|
118
|
+
return;
|
|
119
|
+
} catch (err) {
|
|
120
|
+
if (isNotModified(err)) return;
|
|
121
|
+
// Only recreate when the panel is genuinely gone — a transient failure
|
|
122
|
+
// (429 / network) must NOT spawn a duplicate; skip and retry next time.
|
|
123
|
+
if (!isMessageGone(err)) {
|
|
124
|
+
log.debug("status edit failed (transient), keeping panel:", (err as Error).message);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
log.debug("status panel gone, recreating:", (err as Error).message);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
await this.create(chatId, text);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Remove the pinned panel (unpin + delete) and forget its id. */
|
|
134
|
+
private async remove(chatId: number, id: number): Promise<void> {
|
|
135
|
+
this.settings.update(chatId, { statusMessageId: undefined });
|
|
136
|
+
try {
|
|
137
|
+
await this.api.deleteMessage(chatId, id); // deleting a pinned message also unpins it
|
|
138
|
+
} catch {
|
|
139
|
+
try {
|
|
140
|
+
await this.api.unpinChatMessage(chatId, id);
|
|
141
|
+
} catch {
|
|
142
|
+
/* best-effort */
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private async create(chatId: number, text: string): Promise<void> {
|
|
148
|
+
try {
|
|
149
|
+
const msg = await this.api.sendMessage(chatId, text, { disable_notification: true });
|
|
150
|
+
this.settings.update(chatId, { statusMessageId: msg.message_id });
|
|
151
|
+
await this.api.pinChatMessage(chatId, msg.message_id, { disable_notification: true });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
log.debug("status create/pin failed:", (err as Error).message);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isNotModified(err: unknown): boolean {
|
|
159
|
+
return err instanceof GrammyError && /not modified/i.test(err.description);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** True only when the panel message is genuinely gone (so recreating is the
|
|
163
|
+
* right move) — never for transient errors like 429 or network blips. */
|
|
164
|
+
function isMessageGone(err: unknown): boolean {
|
|
165
|
+
return (
|
|
166
|
+
err instanceof GrammyError &&
|
|
167
|
+
/message to edit not found|message can't be edited|message_id_invalid|message to be edited/i.test(err.description)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function sleep(ms: number): Promise<void> {
|
|
172
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
173
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionService — turns Grok's ACP `session/request_permission` into inline
|
|
3
|
+
* Approve/Deny buttons. It names the session that needs approval, sends the
|
|
4
|
+
* prompt WITH sound (it requires interaction), and — when the request belongs to
|
|
5
|
+
* a *background* session — adds a "🔀 Switch to it" button. The Allow/Deny
|
|
6
|
+
* buttons resolve the request in place, without switching.
|
|
7
|
+
*/
|
|
8
|
+
import type { Api } from "grammy";
|
|
9
|
+
import { InlineKeyboard } from "grammy";
|
|
10
|
+
import type { PermissionOutcome, RequestPermissionParams } from "../grok/types.js";
|
|
11
|
+
import { createLogger } from "../logger.js";
|
|
12
|
+
import type { RuntimeRegistry } from "./registry.js";
|
|
13
|
+
|
|
14
|
+
const log = createLogger("permissions");
|
|
15
|
+
const TIMEOUT_MS = 10 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
const KIND_ICON: Record<string, string> = {
|
|
18
|
+
read: "\u{1F4D6}",
|
|
19
|
+
edit: "\u270F\uFE0F",
|
|
20
|
+
execute: "\u{1F4BB}",
|
|
21
|
+
delete: "\u{1F5D1}\uFE0F",
|
|
22
|
+
move: "\u{1F4E6}",
|
|
23
|
+
fetch: "\u{1F310}",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
interface Pending {
|
|
27
|
+
resolve: (o: PermissionOutcome) => void;
|
|
28
|
+
options: RequestPermissionParams["options"];
|
|
29
|
+
chatId: number;
|
|
30
|
+
sessionId: string;
|
|
31
|
+
messageId?: number;
|
|
32
|
+
timer: NodeJS.Timeout;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class PermissionService {
|
|
36
|
+
private readonly pending = new Map<string, Pending>();
|
|
37
|
+
private seq = 0;
|
|
38
|
+
|
|
39
|
+
constructor(
|
|
40
|
+
private readonly api: Api,
|
|
41
|
+
private readonly registry: RuntimeRegistry,
|
|
42
|
+
) {}
|
|
43
|
+
|
|
44
|
+
/** Handle a permission request: ask the owning chat, or auto-allow if none. */
|
|
45
|
+
async handle(params: RequestPermissionParams): Promise<PermissionOutcome> {
|
|
46
|
+
const desc = this.registry.describeSession(params.sessionId);
|
|
47
|
+
const chatId = desc.chatId;
|
|
48
|
+
if (chatId === undefined) return autoDecide(params); // unattended (e.g. scheduled task / orphan subagent)
|
|
49
|
+
|
|
50
|
+
const reqId = String(++this.seq);
|
|
51
|
+
const isForeground = !desc.subagent && this.registry.get(chatId).sessionId === params.sessionId;
|
|
52
|
+
// A "Switch to it" button only makes sense for a real, controlled background
|
|
53
|
+
// session — never for the foreground, and never for a subagent (which the
|
|
54
|
+
// chat doesn't control directly).
|
|
55
|
+
const canSwitch = desc.controlled && !isForeground;
|
|
56
|
+
const label = desc.subagent
|
|
57
|
+
? desc.subagentName || "subagent"
|
|
58
|
+
: desc.projectName || params.sessionId.slice(0, 8);
|
|
59
|
+
|
|
60
|
+
const kb = new InlineKeyboard();
|
|
61
|
+
params.options.forEach((o, i) => kb.text(buttonLabel(o), `perm:${reqId}:${i}`));
|
|
62
|
+
kb.row();
|
|
63
|
+
if (canSwitch) kb.text(`\u{1F500} Switch to ${label}`, `permsw:${reqId}`);
|
|
64
|
+
|
|
65
|
+
let messageId: number | undefined;
|
|
66
|
+
try {
|
|
67
|
+
const msg = await this.api.sendMessage(
|
|
68
|
+
chatId,
|
|
69
|
+
describe(params, { label: isForeground ? undefined : label, subagent: desc.subagent, canSwitch }),
|
|
70
|
+
{
|
|
71
|
+
reply_markup: kb,
|
|
72
|
+
disable_notification: false, // requires interaction → always with sound
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
messageId = msg.message_id;
|
|
76
|
+
} catch (e) {
|
|
77
|
+
log.warn("failed to send permission prompt:", (e as Error).message);
|
|
78
|
+
return autoDecide(params);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return new Promise<PermissionOutcome>((resolve) => {
|
|
82
|
+
const timer = setTimeout(() => {
|
|
83
|
+
this.pending.delete(reqId);
|
|
84
|
+
void this.api.editMessageText(chatId, messageId!, "\u231B Approval timed out \u2014 denied.").catch(() => {});
|
|
85
|
+
resolve({ outcome: { outcome: "cancelled" } });
|
|
86
|
+
}, TIMEOUT_MS);
|
|
87
|
+
this.pending.set(reqId, { resolve, options: params.options, chatId, sessionId: params.sessionId, messageId, timer });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Resolve a pending request from a button tap; returns the chosen label. */
|
|
92
|
+
resolveChoice(reqId: string, index: number): string | undefined {
|
|
93
|
+
const p = this.pending.get(reqId);
|
|
94
|
+
if (!p) return undefined;
|
|
95
|
+
clearTimeout(p.timer);
|
|
96
|
+
this.pending.delete(reqId);
|
|
97
|
+
const opt = p.options[index];
|
|
98
|
+
if (!opt) {
|
|
99
|
+
p.resolve({ outcome: { outcome: "cancelled" } });
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
p.resolve({ outcome: { outcome: "selected", optionId: opt.optionId } });
|
|
103
|
+
return opt.name;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The session a pending request belongs to (for the Switch button). */
|
|
107
|
+
sessionFor(reqId: string): string | undefined {
|
|
108
|
+
return this.pending.get(reqId)?.sessionId;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describe(
|
|
113
|
+
params: RequestPermissionParams,
|
|
114
|
+
ctx: { label?: string; subagent: boolean; canSwitch: boolean },
|
|
115
|
+
): string {
|
|
116
|
+
const tc = params.toolCall;
|
|
117
|
+
const kind = (tc?.kind || "other").toLowerCase();
|
|
118
|
+
const icon = KIND_ICON[kind] ?? "\u{1F527}";
|
|
119
|
+
const title = tc?.title || kind;
|
|
120
|
+
const raw = (tc?.rawInput || {}) as Record<string, unknown>;
|
|
121
|
+
const cmd = typeof raw.command === "string" ? raw.command : undefined;
|
|
122
|
+
const path = typeof raw.path === "string" ? raw.path : undefined;
|
|
123
|
+
const detail = cmd ? `\n\n$ ${cmd}` : path ? `\n\n${path}` : "";
|
|
124
|
+
const who = ctx.subagent
|
|
125
|
+
? `\u{1F916}\u{1F510} Subagent "${ctx.label}" needs approval to run a tool:`
|
|
126
|
+
: ctx.label
|
|
127
|
+
? `\u{1F510} Session "${ctx.label}" needs approval to run a tool:`
|
|
128
|
+
: "\u{1F510} Grok wants to run a tool:";
|
|
129
|
+
const tail = ctx.canSwitch
|
|
130
|
+
? "\n\nApprove here (no switch), or \u{1F500} switch to that session."
|
|
131
|
+
: ctx.subagent
|
|
132
|
+
? "\n\nApprove for the subagent to continue?"
|
|
133
|
+
: "\n\nApprove?";
|
|
134
|
+
return `${who}\n${icon} ${title}${detail}${tail}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function buttonLabel(o: { name: string; kind?: string }): string {
|
|
138
|
+
const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
|
|
139
|
+
const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
|
|
140
|
+
return `${icon} ${o.name}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Pick an allow option when nobody can be asked (otherwise cancel). */
|
|
144
|
+
function autoDecide(params: RequestPermissionParams): PermissionOutcome {
|
|
145
|
+
const allow = params.options.find((o) => /allow|approve|yes|once/i.test(`${o.kind ?? ""} ${o.name}`));
|
|
146
|
+
return allow
|
|
147
|
+
? { outcome: { outcome: "selected", optionId: allow.optionId } }
|
|
148
|
+
: { outcome: { outcome: "cancelled" } };
|
|
149
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build ACP prompt content blocks from a PromptInput (text + images), applying
|
|
3
|
+
* the reasoning directive and any fork-priming context. Also merges multiple
|
|
4
|
+
* queued inputs into one.
|
|
5
|
+
*/
|
|
6
|
+
import type { ContentBlock } from "../grok/types.js";
|
|
7
|
+
import type { PromptInput } from "../app/types.js";
|
|
8
|
+
|
|
9
|
+
export interface ContentOptions {
|
|
10
|
+
reasoning?: string;
|
|
11
|
+
priming?: string;
|
|
12
|
+
/** Appended at the very bottom so the agent emits a `{progress: N%}` marker. */
|
|
13
|
+
progress?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
|
|
17
|
+
const blocks: ContentBlock[] = [];
|
|
18
|
+
|
|
19
|
+
for (const img of input.images) {
|
|
20
|
+
blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let text = input.text.trim();
|
|
24
|
+
if (!text && input.images.length > 0) {
|
|
25
|
+
text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
|
|
26
|
+
}
|
|
27
|
+
if (input.quotedText?.trim()) {
|
|
28
|
+
const quoted = input.quotedText.trim();
|
|
29
|
+
const body = text || "(the user's reply carried no additional text)";
|
|
30
|
+
text = `The user is replying to this earlier message:\n\n<<<\n${quoted}\n>>>\n\n${body}`;
|
|
31
|
+
}
|
|
32
|
+
if (opts.priming) {
|
|
33
|
+
text = `${opts.priming}\n\n---\n\nUser's new message:\n${text}`;
|
|
34
|
+
}
|
|
35
|
+
if (opts.reasoning) {
|
|
36
|
+
text = `(${opts.reasoning})\n\n${text}`;
|
|
37
|
+
}
|
|
38
|
+
if (opts.progress) {
|
|
39
|
+
text = `${text}\n\n${opts.progress}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
blocks.push({ type: "text", text });
|
|
43
|
+
return blocks;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Merge queued inputs into a single prompt (concatenated text, all images). */
|
|
47
|
+
export function mergeInputs(inputs: PromptInput[]): PromptInput {
|
|
48
|
+
const quotes = inputs
|
|
49
|
+
.map((i) => i.quotedText?.trim())
|
|
50
|
+
.filter((q): q is string => !!q);
|
|
51
|
+
return {
|
|
52
|
+
text: inputs
|
|
53
|
+
.map((i) => i.text)
|
|
54
|
+
.filter((t) => t.trim().length > 0)
|
|
55
|
+
.join("\n\n"),
|
|
56
|
+
images: inputs.flatMap((i) => i.images),
|
|
57
|
+
replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
|
|
58
|
+
quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function imageSummary(input: PromptInput): string {
|
|
63
|
+
return input.images.length > 0 ? ` (+${input.images.length} image${input.images.length > 1 ? "s" : ""})` : "";
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transient-prompt retry policy + the user-facing copy that goes with it.
|
|
3
|
+
*
|
|
4
|
+
* Policy: when a prompt fails with a *transient* agent error (e.g. "high volume
|
|
5
|
+
* of traffic" / -32603 "Internal error") **before any output streamed**, wait
|
|
6
|
+
* and retry with an exponential backoff that starts at 6s and doubles up to a
|
|
7
|
+
* 60s (1 minute) cap, then gives up with a summary. The user always sees the
|
|
8
|
+
* real error text on every attempt — we only add the retry/▶ summary line.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** First backoff delay (ms). */
|
|
12
|
+
export const RETRY_BASE_MS = 6_000;
|
|
13
|
+
/** Maximum backoff delay (ms) — "up to 1 minute". */
|
|
14
|
+
export const RETRY_CAP_MS = 60_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Backoff delays (ms) preceding each retry, doubling from {@link RETRY_BASE_MS}
|
|
18
|
+
* and capped at {@link RETRY_CAP_MS}. The schedule stops once it hits the cap,
|
|
19
|
+
* and never exceeds `maxRetries` entries.
|
|
20
|
+
*
|
|
21
|
+
* `maxRetries >= 5` ⇒ `[6000, 12000, 24000, 48000, 60000]`.
|
|
22
|
+
*/
|
|
23
|
+
export function backoffSchedule(maxRetries: number): number[] {
|
|
24
|
+
const out: number[] = [];
|
|
25
|
+
let delay = RETRY_BASE_MS;
|
|
26
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
27
|
+
out.push(Math.min(delay, RETRY_CAP_MS));
|
|
28
|
+
if (delay >= RETRY_CAP_MS) break;
|
|
29
|
+
delay *= 2;
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Human-friendly seconds label, e.g. 6000 → "6s", 90000 → "1m 30s". */
|
|
35
|
+
export function fmtSeconds(ms: number): string {
|
|
36
|
+
const s = Math.round(ms / 1000);
|
|
37
|
+
if (s < 60) return `${s}s`;
|
|
38
|
+
const m = Math.floor(s / 60);
|
|
39
|
+
const rem = s % 60;
|
|
40
|
+
return rem ? `${m}m ${rem}s` : `${m}m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Message shown when an attempt fails but another retry is scheduled. Shows the
|
|
45
|
+
* real error verbatim so the user can act on it (e.g. switch model), plus when
|
|
46
|
+
* the next attempt runs.
|
|
47
|
+
*/
|
|
48
|
+
export function formatRetryNotice(
|
|
49
|
+
error: Error,
|
|
50
|
+
nextAttempt: number,
|
|
51
|
+
totalAttempts: number,
|
|
52
|
+
waitMs: number,
|
|
53
|
+
): string {
|
|
54
|
+
return [
|
|
55
|
+
`\u26A0\uFE0F ${error.message}`,
|
|
56
|
+
"",
|
|
57
|
+
`\u{1F501} Retrying in ${fmtSeconds(waitMs)} \u2014 attempt ${nextAttempt} of ${totalAttempts}\u2026`,
|
|
58
|
+
].join("\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Final summary shown after all retries are exhausted (or retry was unsafe). */
|
|
62
|
+
export function formatErrorSummary(error: Error, elapsed: string, attempts: number, transient: boolean): string {
|
|
63
|
+
const tip = transient
|
|
64
|
+
? "\n\n\u{1F4A1} Try a different model (tap \u{1F9E9} Model or /model <id>), or send again later."
|
|
65
|
+
: "";
|
|
66
|
+
if (attempts <= 1) {
|
|
67
|
+
return `\u274C Error after ${elapsed}: ${error.message}${tip}`;
|
|
68
|
+
}
|
|
69
|
+
return `\u274C Gave up after ${attempts} attempts over ${elapsed}.\nLast error: ${error.message}${tip}`;
|
|
70
|
+
}
|