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.
Files changed (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * ReauthController — signs in to Grok from chat. `/reauth` offers "Sign in"
3
+ * (runs `grok login`, streaming any verification URL/code to the chat) or
4
+ * "Import existing" (adopt a login already on the host). The agent is taken
5
+ * down before sign-in and restarted after, so it re-binds under the new
6
+ * identity. State is per chat so button callbacks work across updates.
7
+ */
8
+ import { type Api, InlineKeyboard } from "grammy";
9
+ import type { GrokClient } from "../grok/client.js";
10
+ import { AuthService } from "../app/auth-service.js";
11
+ import type { AccountInfo } from "../app/usage.js";
12
+ import { createLogger } from "../logger.js";
13
+
14
+ const log = createLogger("reauth");
15
+
16
+ const LOADER = ["▰▱▱▱▱▱▱", "▰▰▱▱▱▱▱", "▰▰▰▱▱▱▱", "▰▰▰▰▱▱▱", "▰▰▰▰▰▱▱", "▰▰▰▰▰▰▱", "▰▰▰▰▰▰▰"];
17
+ const ANIM_MS = 2500;
18
+ const LOGIN_TIMEOUT_MS = 300_000;
19
+
20
+ type Phase = "choosing" | "login" | "restarting" | "done" | "failed" | "cancelled";
21
+ const ACTIVE: ReadonlySet<Phase> = new Set<Phase>(["login", "restarting"]);
22
+
23
+ interface ReauthSession {
24
+ chatId: number;
25
+ messageId: number;
26
+ phase: Phase;
27
+ abort?: AbortController;
28
+ anim?: NodeJS.Timeout;
29
+ frame: number;
30
+ url?: string;
31
+ code?: string;
32
+ errorMsg?: string;
33
+ accountLabel?: string;
34
+ lastText?: string;
35
+ }
36
+
37
+ /** Pull a verification URL and short code out of streaming login output. */
38
+ function parseLoginOutput(raw: string): { url?: string; code?: string } {
39
+ const text = raw.replace(/\r/g, "");
40
+ const url = text.match(/https?:\/\/[^\s'"<>)\]]+/i)?.[0];
41
+ const code = text.match(/\b[A-Z0-9]{4}-[A-Z0-9]{4}\b/)?.[0] ?? text.match(/code[:\s]+([A-Z0-9][A-Z0-9-]{3,})/i)?.[1];
42
+ return { url, code };
43
+ }
44
+
45
+ export class ReauthController {
46
+ private readonly auth: AuthService;
47
+ private readonly sessions = new Map<number, ReauthSession>();
48
+
49
+ constructor(
50
+ private readonly api: Api,
51
+ private readonly grok: GrokClient,
52
+ grokCliPath: string,
53
+ private readonly getAccount?: () => Promise<AccountInfo | undefined>,
54
+ private readonly verifyLogin?: () => Promise<boolean>,
55
+ ) {
56
+ this.auth = new AuthService(grokCliPath);
57
+ }
58
+
59
+ isBusy(chatId: number): boolean {
60
+ const s = this.sessions.get(chatId);
61
+ return !!s && ACTIVE.has(s.phase);
62
+ }
63
+
64
+ private anyActive(): boolean {
65
+ for (const s of this.sessions.values()) if (ACTIVE.has(s.phase)) return true;
66
+ return false;
67
+ }
68
+
69
+ /** Show the sign-in entry screen. */
70
+ async chooseMethod(chatId: number, existingMessageId?: number): Promise<void> {
71
+ if (this.isBusy(chatId)) return;
72
+ let messageId = existingMessageId;
73
+ if (messageId === undefined) {
74
+ const m = await this.api.sendMessage(chatId, "\u{1F510} Sign in to Grok\u2026").catch(() => undefined);
75
+ if (!m) return;
76
+ messageId = m.message_id;
77
+ }
78
+ const s: ReauthSession = { chatId, messageId, phase: "choosing", frame: 0 };
79
+ this.sessions.set(chatId, s);
80
+ await this.render(s);
81
+ }
82
+
83
+ /** Run `grok logout` + `grok login`, then restart the agent. */
84
+ async beginLogin(chatId: number, messageId: number): Promise<void> {
85
+ if (this.isBusy(chatId) || this.anyActive()) return;
86
+ if (this.grok.hasInflightPrompt()) {
87
+ await this.api.sendMessage(chatId, "\u23F3 Grok is busy running a turn — try /reauth when idle (or /cancel first).").catch(() => {});
88
+ return;
89
+ }
90
+ const s: ReauthSession = this.sessions.get(chatId) ?? { chatId, messageId, phase: "login", frame: 0 };
91
+ s.messageId = messageId;
92
+ s.phase = "login";
93
+ s.errorMsg = undefined;
94
+ s.url = undefined;
95
+ s.code = undefined;
96
+ this.sessions.set(chatId, s);
97
+ void this.run(s);
98
+ }
99
+
100
+ /** Adopt an existing on-host login, then restart the agent. */
101
+ async importExisting(chatId: number, messageId: number): Promise<void> {
102
+ if (this.isBusy(chatId) || this.anyActive()) return;
103
+ const s: ReauthSession = this.sessions.get(chatId) ?? { chatId, messageId, phase: "restarting", frame: 0 };
104
+ s.messageId = messageId;
105
+ const res = await this.auth.importExisting();
106
+ if (!res.ok) {
107
+ s.phase = "failed";
108
+ s.errorMsg = res.error;
109
+ this.sessions.set(chatId, s);
110
+ return void this.render(s);
111
+ }
112
+ s.phase = "restarting";
113
+ s.errorMsg = undefined;
114
+ this.sessions.set(chatId, s);
115
+ this.startAnim(s);
116
+ await this.render(s);
117
+ await this.finishRestart(s);
118
+ }
119
+
120
+ cancel(chatId: number): boolean {
121
+ const s = this.sessions.get(chatId);
122
+ if (!s || !ACTIVE.has(s.phase)) return false;
123
+ s.abort?.abort();
124
+ return true;
125
+ }
126
+
127
+ async cancelChoice(chatId: number, messageId: number): Promise<void> {
128
+ const s = this.sessions.get(chatId);
129
+ if (s && s.phase === "choosing") this.sessions.delete(chatId);
130
+ await this.api.editMessageText(chatId, messageId, "\u{1F510} Sign-in cancelled.").catch(() => {});
131
+ }
132
+
133
+ async retry(chatId: number, messageId: number): Promise<void> {
134
+ await this.chooseMethod(chatId, messageId);
135
+ }
136
+
137
+ // ── flow ───────────────────────────────────────────────────────────────────
138
+
139
+ private async run(s: ReauthSession): Promise<void> {
140
+ s.abort = new AbortController();
141
+ s.accountLabel = undefined;
142
+ let agentDown = false;
143
+ try {
144
+ this.startAnim(s);
145
+ await this.render(s);
146
+ await this.grok.stopAndWait(); // release the agent before sign-in
147
+ agentDown = true;
148
+ await this.auth.logout();
149
+ if (s.abort.signal.aborted) {
150
+ s.phase = "cancelled";
151
+ return;
152
+ }
153
+ let raw = "";
154
+ const result = await this.auth.login({
155
+ timeoutMs: LOGIN_TIMEOUT_MS,
156
+ signal: s.abort.signal,
157
+ onOutput: (t) => {
158
+ raw += t;
159
+ const p = parseLoginOutput(raw);
160
+ let changed = false;
161
+ if (p.url && p.url !== s.url) {
162
+ s.url = p.url;
163
+ changed = true;
164
+ }
165
+ if (p.code && p.code !== s.code) {
166
+ s.code = p.code;
167
+ changed = true;
168
+ }
169
+ if (changed) void this.render(s);
170
+ },
171
+ });
172
+ if (result.cancelled || s.abort.signal.aborted) {
173
+ s.phase = "cancelled";
174
+ return;
175
+ }
176
+ if (!result.ok) {
177
+ s.phase = "failed";
178
+ s.errorMsg = result.error ?? `Sign-in did not complete (exit ${result.code ?? "?"}).`;
179
+ return;
180
+ }
181
+ s.phase = "restarting";
182
+ await this.render(s);
183
+ await this.grok.start();
184
+ agentDown = false;
185
+ s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
186
+ s.phase = "done";
187
+ } catch (e) {
188
+ log.warn("reauth flow failed:", (e as Error).message);
189
+ s.phase = "failed";
190
+ s.errorMsg = (e as Error).message;
191
+ } finally {
192
+ s.abort = undefined;
193
+ this.stopAnim(s);
194
+ if (agentDown) await this.grok.start().catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
195
+ await this.render(s);
196
+ }
197
+ }
198
+
199
+ private async finishRestart(s: ReauthSession): Promise<void> {
200
+ try {
201
+ await this.grok.restart();
202
+ s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
203
+ s.phase = "done";
204
+ } catch (e) {
205
+ s.phase = "failed";
206
+ s.errorMsg = (e as Error).message;
207
+ }
208
+ this.stopAnim(s);
209
+ await this.render(s);
210
+ }
211
+
212
+ private startAnim(s: ReauthSession): void {
213
+ if (s.anim) return;
214
+ s.anim = setInterval(() => {
215
+ s.frame++;
216
+ void this.render(s);
217
+ }, ANIM_MS);
218
+ }
219
+
220
+ private stopAnim(s: ReauthSession): void {
221
+ if (s.anim) {
222
+ clearInterval(s.anim);
223
+ s.anim = undefined;
224
+ }
225
+ }
226
+
227
+ // ── rendering ────────────────────────────────────────────────────────────────
228
+
229
+ private text(s: ReauthSession): string {
230
+ const loader = LOADER[s.frame % LOADER.length] ?? "";
231
+ switch (s.phase) {
232
+ case "choosing":
233
+ return (
234
+ "\u{1F510} Sign in to Grok\n\n" +
235
+ "Grok signs in with your xAI account (SuperGrok / X Premium+).\n\n" +
236
+ "\u2022 \u{1F511} Sign in \u2014 runs `grok login`; if a link/code appears, open it to approve.\n" +
237
+ "\u2022 \u{1F4E5} Import existing \u2014 use a `grok login` already done on this machine."
238
+ );
239
+ case "login": {
240
+ const lines = ["\u{1F511} Signing in to Grok\u2026", ""];
241
+ if (s.url) lines.push(`\u{1F517} Open this link to approve:\n${s.url}`, "");
242
+ if (s.code) lines.push(`\u{1F522} Verification code: ${s.code}`, "");
243
+ if (!s.url && !s.code) lines.push("Starting sign-in (a browser may open on the host)\u2026", "");
244
+ lines.push(`${loader} Waiting\u2026`);
245
+ return lines.join("\n");
246
+ }
247
+ case "restarting":
248
+ return `\u2705 Signed in.\n\u{1F504} Restarting the Grok agent\u2026 ${loader}`;
249
+ case "done":
250
+ return (
251
+ `\u2705 Signed in${s.accountLabel ? ` as ${s.accountLabel}` : ""} and agent restarted.\n` +
252
+ "Your next message runs on this account."
253
+ );
254
+ case "cancelled":
255
+ return "\u{1F6D1} Sign-in cancelled. Tap Retry to try again.";
256
+ case "failed":
257
+ return `\u274C ${s.errorMsg ?? "Sign-in failed."}\nTap Retry to try again.`;
258
+ default:
259
+ return "";
260
+ }
261
+ }
262
+
263
+ private keyboard(s: ReauthSession): InlineKeyboard | undefined {
264
+ switch (s.phase) {
265
+ case "choosing":
266
+ return new InlineKeyboard()
267
+ .text("\u{1F511} Sign in", "reauth:login")
268
+ .text("\u{1F4E5} Import existing", "reauth:import")
269
+ .row()
270
+ .text("\u274C Cancel", "reauth:choose-cancel");
271
+ case "login":
272
+ case "restarting":
273
+ return new InlineKeyboard().text("\u274C Cancel", "reauth:cancel");
274
+ case "cancelled":
275
+ case "failed":
276
+ return new InlineKeyboard().text("\u{1F501} Retry", "reauth:retry");
277
+ default:
278
+ return undefined;
279
+ }
280
+ }
281
+
282
+ private async render(s: ReauthSession): Promise<void> {
283
+ const text = this.text(s);
284
+ if (text === s.lastText) return;
285
+ s.lastText = text;
286
+ await this.api
287
+ .editMessageText(s.chatId, s.messageId, text, {
288
+ reply_markup: this.keyboard(s),
289
+ link_preview_options: { is_disabled: true },
290
+ })
291
+ .catch(() => {});
292
+ }
293
+ }
294
+
295
+ function accountLabel(a: AccountInfo | undefined): string | undefined {
296
+ return a?.email || a?.startUrl || a?.accountType;
297
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Tracks one ChatController per Telegram chat (each controlling one or more
3
+ * sessions). `get(chatId)` returns the chat's foreground SessionRuntime so the
4
+ * existing handlers keep operating on "the current session".
5
+ *
6
+ * It also owns **subagent attribution**: Grok reports a single, process-global
7
+ * subagent list (with no parent session id on the wire), so we attribute new
8
+ * subagents to the chat whose turn is currently running (most-recent first).
9
+ * That mapping drives both subagent *visibility* (routed to the owner's
10
+ * foreground runtime) and *permission* routing (a subagent's permission request
11
+ * is asked of its parent chat).
12
+ */
13
+ import type { Api } from "grammy";
14
+ import type { GrokClient } from "../grok/client.js";
15
+ import type { PendingStage, SubagentInfo } from "../grok/types.js";
16
+ import type { SettingsStore } from "../app/settings-store.js";
17
+ import type { AppConfig } from "../config.js";
18
+ import { subagentSummary } from "../render/subagent.js";
19
+ import type { SessionStore } from "../sessions/store.js";
20
+ import type { AccountRotator } from "./account-rotator.js";
21
+ import { ChatController } from "./chat-controller.js";
22
+ import type { SessionRuntime } from "./session-runtime.js";
23
+
24
+ export interface SessionDescription {
25
+ /** Chat that owns the session (controlled session or subagent parent). */
26
+ chatId?: number;
27
+ /** True when this is a session the chat directly controls. */
28
+ controlled: boolean;
29
+ /** True when this is a subagent of a controlled turn. */
30
+ subagent: boolean;
31
+ projectName?: string;
32
+ subagentName?: string;
33
+ }
34
+
35
+ export class RuntimeRegistry {
36
+ private readonly controllers = new Map<number, ChatController>();
37
+ private refresher: ((chatId: number) => void) | undefined;
38
+ private rotator: AccountRotator | undefined;
39
+ /** Chat ids with a running turn, most-recently-started last. */
40
+ private readonly activeChats: number[] = [];
41
+ /** Subagent sessionId -> owner chat id. */
42
+ private readonly subagentParents = new Map<string, number>();
43
+
44
+ constructor(
45
+ private readonly api: Api,
46
+ private readonly acp: GrokClient,
47
+ private readonly cfg: AppConfig,
48
+ private readonly settings: SettingsStore,
49
+ private readonly store: SessionStore,
50
+ ) {
51
+ this.acp.on("subagents", (subagents, pending) => this.onSubagents(subagents, pending));
52
+ }
53
+
54
+ setRefresher(fn: (chatId: number) => void): void {
55
+ this.refresher = fn;
56
+ }
57
+
58
+ /** Provide the account rotator used for auto-rotate-on-give-up. */
59
+ setAccountRotator(rotator: AccountRotator): void {
60
+ this.rotator = rotator;
61
+ }
62
+
63
+ controller(chatId: number): ChatController {
64
+ let c = this.controllers.get(chatId);
65
+ if (!c) {
66
+ c = new ChatController(
67
+ this.api,
68
+ chatId,
69
+ this.acp,
70
+ this.cfg,
71
+ this.settings,
72
+ this.store,
73
+ (id) => this.refresher?.(id),
74
+ (busy) => this.noteActivity(chatId, busy),
75
+ () => this.rotator,
76
+ );
77
+ this.controllers.set(chatId, c);
78
+ }
79
+ return c;
80
+ }
81
+
82
+ /** The chat's foreground runtime (backward-compatible with existing handlers). */
83
+ get(chatId: number): SessionRuntime {
84
+ return this.controller(chatId).foreground();
85
+ }
86
+
87
+ disposeAll(): void {
88
+ for (const c of this.controllers.values()) c.dispose();
89
+ this.controllers.clear();
90
+ }
91
+
92
+ /** Find the chat that currently controls a given session id. */
93
+ findChatBySession(sessionId: string): number | undefined {
94
+ for (const [chatId, c] of this.controllers) {
95
+ if (c.findBySession(sessionId)) return chatId;
96
+ }
97
+ return undefined;
98
+ }
99
+
100
+ isControlledSession(sessionId: string): boolean {
101
+ return this.findChatBySession(sessionId) !== undefined;
102
+ }
103
+
104
+ /**
105
+ * The chat a session belongs to for permission/routing purposes: a directly
106
+ * controlled session, otherwise the parent chat of a subagent.
107
+ */
108
+ ownerChatForSession(sessionId: string): number | undefined {
109
+ return this.findChatBySession(sessionId) ?? this.subagentParents.get(sessionId);
110
+ }
111
+
112
+ /** Describe a session so a permission prompt can label it correctly. */
113
+ describeSession(sessionId: string): SessionDescription {
114
+ const controlledChat = this.findChatBySession(sessionId);
115
+ if (controlledChat !== undefined) {
116
+ const project = this.controller(controlledChat)
117
+ .list()
118
+ .find((s) => s.sessionId === sessionId)?.projectName;
119
+ return { chatId: controlledChat, controlled: true, subagent: false, projectName: project };
120
+ }
121
+ const parent = this.subagentParents.get(sessionId);
122
+ const info = this.acp.subagentById(sessionId);
123
+ if (parent !== undefined || info) {
124
+ return {
125
+ chatId: parent,
126
+ controlled: false,
127
+ subagent: true,
128
+ subagentName: info?.sessionName || info?.agentName || sessionId.slice(0, 8),
129
+ };
130
+ }
131
+ return { controlled: false, subagent: false };
132
+ }
133
+
134
+ /** Subagent summary line for a chat's status panel, or undefined. */
135
+ subagentSummaryForChat(chatId: number): string | undefined {
136
+ const mine = this.acp.currentSubagents().filter((s) => this.subagentParents.get(s.sessionId) === chatId);
137
+ if (mine.length === 0) return undefined;
138
+ return subagentSummary(mine, this.acp.currentPendingStages());
139
+ }
140
+
141
+ // ── subagent attribution ─────────────────────────────────────────────────
142
+
143
+ private noteActivity(chatId: number, busy: boolean): void {
144
+ const i = this.activeChats.indexOf(chatId);
145
+ if (i !== -1) this.activeChats.splice(i, 1);
146
+ if (busy) this.activeChats.push(chatId);
147
+ }
148
+
149
+ /** The chat most likely to own freshly-spawned subagents. */
150
+ private currentOwner(): number | undefined {
151
+ return this.activeChats.at(-1);
152
+ }
153
+
154
+ private onSubagents(subagents: SubagentInfo[], pending: PendingStage[]): void {
155
+ const owner = this.currentOwner();
156
+ // Record parents for any subagent we haven't attributed yet.
157
+ if (owner !== undefined) {
158
+ for (const s of subagents) {
159
+ if (!this.subagentParents.has(s.sessionId)) this.subagentParents.set(s.sessionId, owner);
160
+ }
161
+ }
162
+ // Group by attributed chat and route visibility to each owner's foreground.
163
+ const byChat = new Map<number, SubagentInfo[]>();
164
+ for (const s of subagents) {
165
+ const chatId = this.subagentParents.get(s.sessionId);
166
+ if (chatId === undefined) continue;
167
+ const arr = byChat.get(chatId);
168
+ if (arr) arr.push(s);
169
+ else byChat.set(chatId, [s]);
170
+ }
171
+ for (const [chatId, list] of byChat) {
172
+ try {
173
+ this.controller(chatId).foreground().renderSubagents(list, pending);
174
+ } catch {
175
+ /* non-fatal */
176
+ }
177
+ this.refresher?.(chatId);
178
+ }
179
+ // Prune mappings for subagents no longer present (they're terminated and
180
+ // won't issue further permission requests) so the map stays bounded.
181
+ const live = new Set(subagents.map((s) => s.sessionId));
182
+ for (const sid of this.subagentParents.keys()) {
183
+ if (!live.has(sid)) this.subagentParents.delete(sid);
184
+ }
185
+ }
186
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Reply context extraction.
3
+ *
4
+ * When a user's Telegram message is a *reply* to another message, the agent
5
+ * should also receive what the user is responding to — otherwise a terse reply
6
+ * like "fix this" or "why?" loses all meaning. This module pulls that reference
7
+ * content out of an incoming message:
8
+ * 1. an explicitly highlighted quote (Bot API 6.9+ `message.quote`), which is
9
+ * the most precise signal of what the user is pointing at, then
10
+ * 2. the full replied-to message's text / caption / media descriptor, then
11
+ * 3. a reply to a message from another chat (`external_reply`).
12
+ *
13
+ * The result is capped so a reply to a huge agent answer can't blow the prompt.
14
+ */
15
+ import type { Context } from "grammy";
16
+
17
+ /** Max characters of quoted context forwarded to the agent. */
18
+ const MAX_QUOTE_CHARS = 4000;
19
+
20
+ /** Structural subset of a Telegram message we can summarize. */
21
+ interface QuotableMessage {
22
+ text?: string;
23
+ caption?: string;
24
+ document?: { file_name?: string };
25
+ photo?: unknown;
26
+ voice?: unknown;
27
+ audio?: { file_name?: string };
28
+ video?: unknown;
29
+ video_note?: unknown;
30
+ sticker?: { emoji?: string };
31
+ location?: unknown;
32
+ contact?: unknown;
33
+ }
34
+
35
+ /**
36
+ * Build the reference-content string for a reply, or `undefined` when the
37
+ * message isn't a reply / carries nothing quotable.
38
+ */
39
+ export function extractReplyContext(ctx: Context): string | undefined {
40
+ const msg = ctx.message;
41
+ if (!msg) return undefined;
42
+
43
+ const quote = msg.quote?.text?.trim() || undefined;
44
+ const reply = msg.reply_to_message ? describeMessage(msg.reply_to_message) : undefined;
45
+ const external = msg.external_reply ? describeMessage(msg.external_reply as QuotableMessage) : undefined;
46
+
47
+ let context: string | undefined;
48
+ if (quote && reply && reply !== quote) {
49
+ // The user highlighted a specific part of a larger message — surface the
50
+ // excerpt first (so it survives clipping) plus the fuller message context.
51
+ context = `Quoted excerpt: "${quote}"\nFrom the message: ${reply}`;
52
+ } else {
53
+ context = quote ?? reply ?? external;
54
+ }
55
+
56
+ if (!context) return undefined;
57
+ return clip(context, MAX_QUOTE_CHARS);
58
+ }
59
+
60
+ /** Human-readable content of a message: its text/caption or a media label. */
61
+ function describeMessage(m: QuotableMessage): string | undefined {
62
+ const body = (m.text ?? m.caption)?.trim();
63
+ if (body) return body;
64
+ if (m.document) return `[file: ${m.document.file_name ?? "document"}]`;
65
+ if (m.photo) return "[photo]";
66
+ if (m.voice) return "[voice message]";
67
+ if (m.audio) return `[audio: ${m.audio.file_name ?? "audio"}]`;
68
+ if (m.video || m.video_note) return "[video]";
69
+ if (m.sticker) return `[sticker${m.sticker.emoji ? ` ${m.sticker.emoji}` : ""}]`;
70
+ if (m.location) return "[location]";
71
+ if (m.contact) return "[contact]";
72
+ return undefined;
73
+ }
74
+
75
+ function clip(s: string, max: number): string {
76
+ return s.length <= max ? s : `${s.slice(0, max)}\n…(truncated)`;
77
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Session forking helpers — "logical fork" of a Grok session.
3
+ *
4
+ * A fork is a fresh session in the same project, *primed* with the recent
5
+ * transcript of the session it continues, so the conversation survives when the
6
+ * original can't be used: its exclusive lock is held by another window, or it
7
+ * got throttled / exhausted / stuck mid-turn. Used by:
8
+ * • lost-session recovery (a persisted session we can't reload), and
9
+ * • auto-fork-on-error (a transient prompt failure with no streamed output).
10
+ */
11
+ import { join } from "node:path";
12
+ import { buildTranscript, readHistory } from "../sessions/history.js";
13
+
14
+ /** Read a compact transcript of a session's recent history from disk, or "". */
15
+ export function recentTranscript(sessionsDir: string, sessionId: string, entries = 24): string {
16
+ try {
17
+ const hist = readHistory(join(sessionsDir, `${sessionId}.jsonl`), entries);
18
+ return hist.length > 0 ? buildTranscript(hist) : "";
19
+ } catch {
20
+ return "";
21
+ }
22
+ }
23
+
24
+ /** Priming preamble injected as context into a forked (linked) continuation. */
25
+ export function buildPriming(transcript: string): string {
26
+ return [
27
+ "You are resuming a conversation that is currently still running in another",
28
+ "window on this machine, so this is a linked continuation. Below is the recent",
29
+ "transcript for context — use it to continue seamlessly.",
30
+ "",
31
+ "=== RECENT TRANSCRIPT ===",
32
+ transcript,
33
+ "=== END TRANSCRIPT ===",
34
+ ].join("\n");
35
+ }