grok-telegram-bot 2.6.0 → 2.7.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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Interactive plan-mode approval. Grok's exit_plan_mode reverse request
3
+ * becomes Approve / Request changes / Abandon buttons (same idea as
4
+ * PermissionService). Auto-approve is used when configured, or when the
5
+ * session has no owning chat (scheduled / orphan).
6
+ */
7
+ import type { Api } from "grammy";
8
+ import { InlineKeyboard } from "grammy";
9
+ import type { PlanExitDecision, PlanExitOutcome } from "../grok/plan-approval.js";
10
+ import { outboundThreadExtra } from "../forum/thread.js";
11
+ import { createLogger } from "../logger.js";
12
+ import type { RuntimeRegistry } from "./registry.js";
13
+
14
+ const log = createLogger("plan-exit");
15
+ const TIMEOUT_MS = 30 * 60 * 1000;
16
+ const PREVIEW = 900;
17
+
18
+ interface Pending {
19
+ resolve: (d: PlanExitDecision) => void;
20
+ chatId: number;
21
+ messageId?: number;
22
+ timer: NodeJS.Timeout;
23
+ pinned: boolean;
24
+ waitingFeedback: boolean;
25
+ }
26
+
27
+ export class PlanExitService {
28
+ private readonly pending = new Map<string, Pending>();
29
+ /** chatId → reqId while we wait for revision notes after "Request changes". */
30
+ private readonly feedbackFor = new Map<number, string>();
31
+ private seq = 0;
32
+
33
+ constructor(
34
+ private readonly api: Api,
35
+ private readonly registry: RuntimeRegistry,
36
+ public autoApprove = false,
37
+ private readonly onUnpinned?: (chatId: number) => void | Promise<void>,
38
+ ) {}
39
+
40
+ async handle(params: Record<string, unknown>): Promise<PlanExitDecision> {
41
+ const sessionId = str(params.sessionId) || str(params.session_id) || "";
42
+ const planText = extractPlanText(params);
43
+ if (this.autoApprove) {
44
+ log.info(`auto-approved plan exit for ${sessionId.slice(0, 8) || "?"}`);
45
+ return { outcome: "approved", feedback: "" };
46
+ }
47
+
48
+ const desc = sessionId ? this.registry.describeSession(sessionId) : { chatId: undefined };
49
+ const chatId = desc.chatId;
50
+ if (chatId === undefined) return { outcome: "approved", feedback: "" };
51
+ const threadExtra = outboundThreadExtra(desc.threadId);
52
+
53
+ const reqId = String(++this.seq);
54
+ const preview = planText.replace(/\s+/g, " ").trim().slice(0, PREVIEW);
55
+ const body = [
56
+ "\u{1F4CB} Plan ready \u2014 review before Grok implements.",
57
+ preview ? `\n${preview}${planText.length > PREVIEW ? "\u2026" : ""}` : "\n(No plan text in the request.)",
58
+ "\nApprove to build, request changes (then send notes), or abandon.",
59
+ ].join("\n");
60
+
61
+ const kb = new InlineKeyboard()
62
+ .text("\u2705 Approve", `planx:${reqId}:ok`)
63
+ .text("\u270F\uFE0F Changes", `planx:${reqId}:chg`)
64
+ .row()
65
+ .text("\u26D4 Abandon", `planx:${reqId}:no`);
66
+
67
+ let messageId: number | undefined;
68
+ let pinned = false;
69
+ try {
70
+ const msg = await this.api.sendMessage(chatId, body, {
71
+ reply_markup: kb,
72
+ disable_notification: false,
73
+ ...threadExtra,
74
+ });
75
+ messageId = msg.message_id;
76
+ try {
77
+ await this.api.pinChatMessage(chatId, messageId, { disable_notification: true });
78
+ pinned = true;
79
+ } catch (e) {
80
+ log.warn("pin plan prompt failed:", (e as Error).message);
81
+ }
82
+ } catch (e) {
83
+ log.warn("send plan prompt failed:", (e as Error).message);
84
+ return { outcome: "approved", feedback: "" };
85
+ }
86
+
87
+ return new Promise<PlanExitDecision>((resolve) => {
88
+ const timer = setTimeout(() => {
89
+ const p = this.pending.get(reqId);
90
+ if (!p) return;
91
+ this.pending.delete(reqId);
92
+ this.feedbackFor.delete(p.chatId);
93
+ void this.finish(p, "\u231B Plan approval timed out \u2014 abandoned.");
94
+ resolve({ outcome: "abandoned", feedback: "timed out waiting for review" });
95
+ }, TIMEOUT_MS);
96
+ this.pending.set(reqId, { resolve, chatId, messageId, timer, pinned, waitingFeedback: false });
97
+ });
98
+ }
99
+
100
+ /** Button tap. Returns a short toast; "chg" waits for the next text message. */
101
+ resolveChoice(reqId: string, action: string): string | undefined {
102
+ const p = this.pending.get(reqId);
103
+ if (!p) return undefined;
104
+ if (action === "chg") {
105
+ p.waitingFeedback = true;
106
+ this.feedbackFor.set(p.chatId, reqId);
107
+ void this.api
108
+ .editMessageText(p.chatId, p.messageId ?? 0, "\u270F\uFE0F Send revision notes as your next message.", {
109
+ reply_markup: { inline_keyboard: [] },
110
+ })
111
+ .catch(() => {});
112
+ return "Send change notes";
113
+ }
114
+ clearTimeout(p.timer);
115
+ this.pending.delete(reqId);
116
+ this.feedbackFor.delete(p.chatId);
117
+ const outcome: PlanExitOutcome = action === "ok" ? "approved" : "abandoned";
118
+ const label = outcome === "approved" ? "\u2705 Plan approved \u2014 implementing." : "\u26D4 Plan abandoned.";
119
+ void this.finish(p, label);
120
+ p.resolve({ outcome, feedback: "" });
121
+ return outcome === "approved" ? "Approved" : "Abandoned";
122
+ }
123
+
124
+ /** If this chat is waiting for revision notes, consume the text. */
125
+ takeFeedback(chatId: number, text: string): boolean {
126
+ const reqId = this.feedbackFor.get(chatId);
127
+ if (!reqId) return false;
128
+ const p = this.pending.get(reqId);
129
+ this.feedbackFor.delete(chatId);
130
+ if (!p) return false;
131
+ clearTimeout(p.timer);
132
+ this.pending.delete(reqId);
133
+ const notes = text.trim().slice(0, 4000);
134
+ void this.finish(p, `\u270F\uFE0F Requested changes:\n${notes.slice(0, 400)}`);
135
+ p.resolve({ outcome: "request_changes", feedback: notes || "Please revise the plan." });
136
+ return true;
137
+ }
138
+
139
+ private async finish(p: Pending, text: string): Promise<void> {
140
+ if (p.messageId !== undefined) {
141
+ await this.api
142
+ .editMessageText(p.chatId, p.messageId, text, { reply_markup: { inline_keyboard: [] } })
143
+ .catch(() => {});
144
+ }
145
+ if (p.pinned && p.messageId !== undefined) {
146
+ p.pinned = false;
147
+ await this.api.unpinChatMessage(p.chatId, p.messageId).catch(() => {});
148
+ }
149
+ if (this.onUnpinned) {
150
+ try {
151
+ await this.onUnpinned(p.chatId);
152
+ } catch {
153
+ /* non-fatal */
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ export function extractPlanText(params: Record<string, unknown>): string {
160
+ for (const k of ["plan_content", "planContent", "content", "plan", "text"]) {
161
+ const v = params[k];
162
+ if (typeof v === "string" && v.trim()) return v;
163
+ }
164
+ return "";
165
+ }
166
+
167
+ function str(v: unknown): string {
168
+ return typeof v === "string" ? v : "";
169
+ }
@@ -24,6 +24,8 @@ import type { SessionRuntime } from "./session-runtime.js";
24
24
  export interface SessionDescription {
25
25
  /** Chat that owns the session (controlled session or subagent parent). */
26
26
  chatId?: number;
27
+ /** Forum topic thread id when the session lives in a project topic. */
28
+ threadId?: number;
27
29
  /** True when this is a session the chat directly controls. */
28
30
  controlled: boolean;
29
31
  /** True when this is a subagent of a controlled turn. */
@@ -202,10 +204,17 @@ export class RuntimeRegistry {
202
204
  describeSession(sessionId: string): SessionDescription {
203
205
  const controlledChat = this.findChatBySession(sessionId);
204
206
  if (controlledChat !== undefined) {
205
- const project = this.controller(controlledChat)
207
+ const forum = this.forumControllerForSession(sessionId);
208
+ const project = (forum ?? this.controller(controlledChat))
206
209
  .list()
207
210
  .find((s) => s.sessionId === sessionId)?.projectName;
208
- return { chatId: controlledChat, controlled: true, subagent: false, projectName: project };
211
+ return {
212
+ chatId: controlledChat,
213
+ threadId: forum?.messageThreadId,
214
+ controlled: true,
215
+ subagent: false,
216
+ projectName: project,
217
+ };
209
218
  }
210
219
  const parent = this.subagentParents.get(sessionId);
211
220
  const info = this.acp.subagentById(sessionId);
@@ -846,6 +846,7 @@ export class SessionRuntime {
846
846
  // Never wrap meta follow-ups even if somehow first.
847
847
  if (
848
848
  input.skipSelfRecheck ||
849
+ input.rawSlashCommand ||
849
850
  isSelfRecheckPrompt(input.text) ||
850
851
  isTelegramBridgeResultsPrompt(input.text) ||
851
852
  isManagerWorkReportPrompt(input.text)
@@ -870,6 +871,7 @@ export class SessionRuntime {
870
871
  private applyManagerContext(input: PromptInput): PromptInput {
871
872
  if (!this.managerMode) return input;
872
873
  if (
874
+ input.rawSlashCommand ||
873
875
  isTelegramBridgeResultsPrompt(input.text) ||
874
876
  isManagerWorkReportPrompt(input.text) ||
875
877
  isSelfRecheckPrompt(input.text)
package/src/cli.ts CHANGED
@@ -10,16 +10,17 @@
10
10
  import { spawnSync } from "node:child_process";
11
11
  import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
+ import { listKnownInstances, stripInstanceFlags } from "./app/instance.js";
13
14
  import { ENV_PATH, INSTANCE_DIR, PROJECT_ROOT } from "./config.js";
14
15
  import { buildLaunchSpec, getController } from "./service/index.js";
15
16
 
16
17
  const HELP = `Grok Telegram Bot — CLI
17
18
 
18
- Usage: grok-tg <command>
19
+ Usage: grok-tg [--name <slug>] [--instance <dir>] <command>
19
20
 
20
21
  run Run in the foreground
21
- setup [--path] Create/update .env (default ~/.grok/tg/.env, loaded from
22
- any folder); --path just prints the resolved .env location
22
+ setup [--path] Create/update .env (default ~/.grok/tg/.env);
23
+ --name <slug> writes ~/.grok/tg/instances/<slug>/.env
23
24
  install Install + start a background service (autostart on boot)
24
25
  uninstall Stop + remove the background service
25
26
  start Start the service
@@ -27,12 +28,20 @@ Usage: grok-tg <command>
27
28
  restart Restart the service
28
29
  status Show install + running status
29
30
  logs [n] Show the last n log lines (default 100)
31
+ instances List named bot instances on this host
30
32
  help Show this help
33
+
34
+ Several Telegram bots on one host (one chat per project):
35
+
36
+ grok-tg --name work setup <BOT_TOKEN> <YOUR_USER_ID>
37
+ grok-tg --name work install
38
+ grok-tg --name work status
31
39
  `;
32
40
 
33
41
  async function main(): Promise<void> {
34
42
  const args = process.argv.slice(2);
35
- const [cmd, arg] = args;
43
+ const rest = stripInstanceFlags(args);
44
+ const [cmd, arg] = rest;
36
45
 
37
46
  switch (cmd) {
38
47
  case "run":
@@ -45,9 +54,9 @@ async function main(): Promise<void> {
45
54
  // Run the plain-node setup script, targeting this folder (.env lives in
46
55
  // the instance dir). Pass through optional <token> [userId] args.
47
56
  const script = join(PROJECT_ROOT, "scripts", "setup.mjs");
48
- const r = spawnSync(process.execPath, [script, ...args.slice(1)], {
57
+ const r = spawnSync(process.execPath, [script, "--instance", INSTANCE_DIR, ...rest.slice(1)], {
49
58
  stdio: "inherit",
50
- env: { ...process.env, GROK_TG_CWD: INSTANCE_DIR },
59
+ env: { ...process.env, GROK_TG_DIR: INSTANCE_DIR, GROK_TG_CWD: INSTANCE_DIR },
51
60
  });
52
61
  process.exit(r.status ?? 0);
53
62
  break;
@@ -57,7 +66,11 @@ async function main(): Promise<void> {
57
66
  preflight();
58
67
  const r = await getController().install(buildLaunchSpec());
59
68
  console.log(r.ok ? `✓ ${r.message}` : `✗ ${r.message}`);
60
- if (r.ok) console.log("\nManage it with: grok-tg status | stop | restart | logs");
69
+ if (r.ok) {
70
+ const spec = buildLaunchSpec();
71
+ const flag = spec.slug ? `--name ${spec.slug} ` : "";
72
+ console.log(`\nManage it with: grok-tg ${flag}status | stop | restart | logs`);
73
+ }
61
74
  process.exit(r.ok ? 0 : 1);
62
75
  break;
63
76
  }
@@ -86,6 +99,11 @@ async function main(): Promise<void> {
86
99
  process.exit(0);
87
100
  break;
88
101
 
102
+ case "instances":
103
+ printInstances();
104
+ process.exit(0);
105
+ break;
106
+
89
107
  case "help":
90
108
  case "--help":
91
109
  case "-h":
@@ -112,6 +130,24 @@ function preflight(): void {
112
130
  }
113
131
  }
114
132
 
133
+ function printInstances(): void {
134
+ const items = listKnownInstances();
135
+ if (items.length === 0) {
136
+ console.log("No instances found.");
137
+ console.log(" grok-tg setup # default bot (~/.grok/tg)");
138
+ console.log(" grok-tg --name work setup <token> <userId> # second bot");
139
+ return;
140
+ }
141
+ console.log("Instances:\n");
142
+ for (const it of items) {
143
+ const manage = it.slug ? `grok-tg --name ${it.slug}` : "grok-tg";
144
+ console.log(` ${it.name}`);
145
+ console.log(` dir: ${it.dir}`);
146
+ console.log(` service: ${it.identity.id}`);
147
+ console.log(` manage: ${manage} status | restart | logs\n`);
148
+ }
149
+ }
150
+
115
151
  function printLogs(n: number): void {
116
152
  const file = buildLaunchSpec().logFile;
117
153
  if (!existsSync(file)) {
package/src/config.ts CHANGED
@@ -10,51 +10,42 @@ import { existsSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { dirname, isAbsolute, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
+ import {
14
+ CANONICAL_DIR,
15
+ expandHome,
16
+ resolveInstanceDir as resolveNamedInstanceDir,
17
+ } from "./app/instance.js";
18
+
19
+ export { CANONICAL_DIR, expandHome };
13
20
 
14
21
  /** Absolute path to the installed bot code (one level above src/). For a global
15
22
  * npm install this lives inside node_modules — code lives here, never user data. */
16
23
  export const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17
24
 
18
- /** Canonical, path-independent home for this bot's `.env`, `logs/`, `data/` and
19
- * the single-instance locks: `~/.grok/tg`. Used whenever the bot is started
20
- * without an explicit instance dir and there's no `.env` in the current folder,
21
- * so the SAME configuration is found no matter which directory you launch from. */
22
- export const CANONICAL_DIR = join(homedir(), ".grok", "tg");
23
-
24
25
  /**
25
26
  * Directory holding THIS instance's `.env`, `logs/` and `data/`. Resolution
26
27
  * (first match wins):
27
28
  * 1. `--instance <dir>` argv — set by the installed background service,
28
- * 2. `GROK_TG_DIR` env an explicit override,
29
- * 3. `GROK_TG_CWD` env — the legacy launcher variable,
30
- * 4. the current folder, IF it already contains a `.env`,
29
+ * 2. `--name` / `GROK_TG_NAME`named instance under `~/.grok/tg/instances/`,
30
+ * 3. `GROK_TG_DIR` env — an explicit override,
31
+ * 4. `GROK_TG_CWD` or cwd IF that folder already contains a `.env`,
31
32
  * 5. the canonical `~/.grok/tg` home — the path-independent default.
32
33
  */
33
- export const INSTANCE_DIR = resolveInstanceDir();
34
+ export const INSTANCE_DIR = resolveNamedInstanceDir({
35
+ argv: process.argv,
36
+ envDir: process.env.GROK_TG_DIR,
37
+ nameEnv: process.env.GROK_TG_NAME,
38
+ cwdHint: process.env.GROK_TG_CWD,
39
+ });
34
40
 
35
41
  /** Absolute path to the `.env` this instance loads (and that `setup` writes). */
36
42
  export const ENV_PATH = join(INSTANCE_DIR, ".env");
37
43
 
38
- function resolveInstanceDir(): string {
39
- const flag = process.argv.indexOf("--instance");
40
- if (flag !== -1 && process.argv[flag + 1]) return resolve(process.argv[flag + 1]!);
41
- const envDir = process.env.GROK_TG_DIR?.trim() || process.env.GROK_TG_CWD?.trim();
42
- if (envDir) return resolve(expandHome(envDir));
43
- if (existsSync(join(process.cwd(), ".env"))) return process.cwd();
44
- return CANONICAL_DIR;
45
- }
46
-
47
44
  // Load .env from the resolved instance directory. Keep the parsed values as
48
45
  // well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex,
49
46
  // Kiro, etc.) and must never override this Grok instance's identity.
50
47
  const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {};
51
48
 
52
- function expandHome(p: string): string {
53
- if (p === "~") return homedir();
54
- if (p.startsWith("~/") || p.startsWith("~\\")) return join(homedir(), p.slice(2));
55
- return p;
56
- }
57
-
58
49
  function bool(v: string | undefined, def: boolean): boolean {
59
50
  if (v === undefined || v === "") return def;
60
51
  return ["1", "true", "yes", "on"].includes(v.toLowerCase());
@@ -142,6 +133,20 @@ export interface AppConfig {
142
133
  * Approve/Deny buttons.
143
134
  */
144
135
  autoApprovePermissions: boolean;
136
+ /**
137
+ * Auto-approve Grok plan-mode exit (no Approve/Changes/Abandon buttons).
138
+ * Default true so unattended/24/7 bots never wait on a TUI. Set false for
139
+ * interactive review in Telegram.
140
+ */
141
+ autoApprovePlan: boolean;
142
+ /** GROK_SANDBOX profile (workspace-safe, strict, off, …). */
143
+ sandboxProfile?: string;
144
+ /** GROK_MEMORY setting forwarded to the agent process. */
145
+ grokMemory?: string;
146
+ /** `--agent-profile` for `grok agent`. */
147
+ agentProfile?: string;
148
+ /** `--plugin-dir` for `grok agent`. */
149
+ pluginDir?: string;
145
150
  projectRoots: string[];
146
151
  streamThrottleMs: number;
147
152
  messageBatchMs: number;
@@ -301,6 +306,11 @@ export function loadConfig(): AppConfig {
301
306
  trustAllTools: bool(process.env.GROK_TRUST_ALL_TOOLS, true),
302
307
  // Default true: auto-approve with session-scope when the agent still asks.
303
308
  autoApprovePermissions: bool(process.env.AUTO_APPROVE_PERMISSIONS, true),
309
+ autoApprovePlan: bool(process.env.AUTO_APPROVE_PLAN, true),
310
+ sandboxProfile: process.env.GROK_SANDBOX?.trim() || undefined,
311
+ grokMemory: process.env.GROK_MEMORY?.trim() || undefined,
312
+ agentProfile: process.env.GROK_AGENT_PROFILE?.trim() || undefined,
313
+ pluginDir: process.env.GROK_PLUGIN_DIR?.trim() || undefined,
304
314
  projectRoots: [...new Set(roots)],
305
315
  streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500),
306
316
  messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800),
@@ -218,6 +218,10 @@ export interface GrokClientOptions {
218
218
  autoRestart?: boolean;
219
219
  promptIdleTimeoutMs?: number;
220
220
  promptMaxMs?: number;
221
+ sandboxProfile?: string;
222
+ grokMemory?: string;
223
+ agentProfile?: string;
224
+ pluginDir?: string;
221
225
  }
222
226
 
223
227
  interface Pending {
@@ -288,6 +292,10 @@ export class GrokClient extends EventEmitter {
288
292
  * outcomes). Must never kill the agent process.
289
293
  */
290
294
  onSessionCancel?: (sessionId: string) => void;
295
+ /** Interactive (or auto) plan-mode exit. Default: auto-approve. */
296
+ planExitHandler?: (params: Record<string, unknown>) => Promise<unknown>;
297
+ /** Interactive (or skip) ask_user_question. Default: SkipInterview. */
298
+ askUserHandler?: (params: Record<string, unknown>) => Promise<unknown>;
291
299
 
292
300
  constructor(private readonly opts: GrokClientOptions) {
293
301
  super();
@@ -313,11 +321,15 @@ export class GrokClient extends EventEmitter {
313
321
  // the new token. `--no-auto-update` was removed in grok 0.2.x (exit 2).
314
322
  const args = ["agent", "--no-leader"];
315
323
  if (this.opts.trustAllTools) args.push("--always-approve");
324
+ if (this.opts.agentProfile) args.push("--agent-profile", this.opts.agentProfile);
325
+ if (this.opts.pluginDir) args.push("--plugin-dir", this.opts.pluginDir);
316
326
  args.push("stdio");
317
327
 
318
328
  log.info(`spawning: ${this.opts.grokCliPath} ${args.join(" ")}`);
319
329
  const env = { ...process.env };
320
330
  if (this.opts.apiKey) env.XAI_API_KEY = this.opts.apiKey;
331
+ if (this.opts.sandboxProfile) env.GROK_SANDBOX = this.opts.sandboxProfile;
332
+ if (this.opts.grokMemory) env.GROK_MEMORY = this.opts.grokMemory;
321
333
  const proc = spawn(this.opts.grokCliPath, args, {
322
334
  stdio: ["pipe", "pipe", "pipe"],
323
335
  cwd: this.opts.workspace,
@@ -419,7 +431,11 @@ export class GrokClient extends EventEmitter {
419
431
  }
420
432
 
421
433
  async newSession(cwd: string): Promise<string> {
422
- const res = (await this.request("session/new", { cwd, mcpServers: [] })) as { sessionId: string };
434
+ const res = (await this.request("session/new", {
435
+ cwd,
436
+ mcpServers: [],
437
+ ...(this.opts.trustAllTools ? { _meta: { yoloMode: true } } : {}),
438
+ })) as { sessionId: string };
423
439
  this.parseSessionExtras(res);
424
440
  this.cwd.set(res.sessionId, cwd);
425
441
  this.slog.create(res.sessionId, cwd);
@@ -622,6 +638,12 @@ export class GrokClient extends EventEmitter {
622
638
  return this.request("_grok.dev/commands/execute", { sessionId, command });
623
639
  }
624
640
 
641
+ /** Update spawn-time agent env (applied on the next `grok agent` restart). */
642
+ setAgentOptions(opts: { sandboxProfile?: string; grokMemory?: string }): void {
643
+ if (opts.sandboxProfile !== undefined) this.opts.sandboxProfile = opts.sandboxProfile;
644
+ if (opts.grokMemory !== undefined) this.opts.grokMemory = opts.grokMemory;
645
+ }
646
+
625
647
  stop(): void {
626
648
  this.stopped = true;
627
649
  if (this.restartTimer) {
@@ -780,11 +802,13 @@ export class GrokClient extends EventEmitter {
780
802
  (planSnippet ? ` plan=${planSnippet.replace(/\s+/g, " ").slice(0, 80)}` : "") +
781
803
  (keys ? ` keys=[${keys}]` : ""),
782
804
  );
783
- result = autoApproveExitPlanMode(params);
805
+ result = this.planExitHandler
806
+ ? await this.planExitHandler(params)
807
+ : autoApproveExitPlanMode(params);
784
808
  } else if (isAskUserQuestionMethod(method)) {
785
- // No TUI question form: skip so the agent continues (prefer later Telegram UI).
786
- log.info(`auto-skipping ${method} (no interactive question UI in Telegram bridge)`);
787
- result = autoSkipAskUserQuestion(params);
809
+ result = this.askUserHandler
810
+ ? await this.askUserHandler(params)
811
+ : autoSkipAskUserQuestion(params);
788
812
  } else {
789
813
  // We advertise no fs/terminal capabilities, so the agent shouldn't ask.
790
814
  // Log at warn — unknown reverse methods used to silently break plan exit
@@ -24,6 +24,14 @@ export const EXT_EXIT_PLAN_MODE_ALT = "x.ai/exit_plan_mode";
24
24
  export const EXT_ASK_USER_QUESTION = "_x.ai/ask_user_question";
25
25
  export const EXT_ASK_USER_QUESTION_ALT = "x.ai/ask_user_question";
26
26
 
27
+ /** Plan approval outcomes accepted by Grok Build's ExitPlanModeExtResponse. */
28
+ export type PlanExitOutcome = "approved" | "abandoned" | "request_changes";
29
+
30
+ export interface PlanExitDecision {
31
+ outcome: PlanExitOutcome;
32
+ feedback?: string;
33
+ }
34
+
27
35
  /**
28
36
  * Auto-approve leaving plan mode so the agent can implement.
29
37
  *
package/src/index.ts CHANGED
@@ -146,6 +146,10 @@ async function main(): Promise<void> {
146
146
  model: cfg.grokModel,
147
147
  autoRestart: cfg.grokAutoRestart,
148
148
  promptIdleTimeoutMs: cfg.promptIdleMs,
149
+ sandboxProfile: cfg.sandboxProfile,
150
+ grokMemory: cfg.grokMemory,
151
+ agentProfile: cfg.agentProfile,
152
+ pluginDir: cfg.pluginDir,
149
153
  });
150
154
 
151
155
  // Retry ACP connect — agent crash at boot should not kill the Telegram bot.
@@ -11,8 +11,12 @@ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
11
11
 
12
12
  const UNIT = "grok-telegram-bot.service";
13
13
 
14
- function unitPath(): string {
15
- return join(homedir(), ".config", "systemd", "user", UNIT);
14
+ function unitName(spec?: { id?: string }): string {
15
+ return spec?.id ? `${spec.id}.service` : UNIT;
16
+ }
17
+
18
+ function unitPath(spec?: { id?: string }): string {
19
+ return join(homedir(), ".config", "systemd", "user", unitName(spec));
16
20
  }
17
21
 
18
22
  export const linuxController: ServiceController = {
@@ -21,35 +25,37 @@ export const linuxController: ServiceController = {
21
25
  async install(spec) {
22
26
  mkdirSync(join(homedir(), ".config", "systemd", "user"), { recursive: true });
23
27
  mkdirSync(spec.logsDir, { recursive: true });
24
- writeFileSync(unitPath(), unitFile(spec), "utf-8");
28
+ const unit = unitName(spec);
29
+ writeFileSync(unitPath(spec), unitFile(spec), "utf-8");
25
30
 
26
31
  runSafe("systemctl", ["--user", "daemon-reload"]);
27
- const en = runSafe("systemctl", ["--user", "enable", "--now", UNIT]);
32
+ const en = runSafe("systemctl", ["--user", "enable", "--now", unit]);
28
33
  if (!en.ok) return fail(`systemctl enable failed: ${en.out}`);
29
34
  const linger = runSafe("loginctl", ["enable-linger", userInfo().username]);
30
35
  const note = linger.ok ? " Boot-without-login enabled (linger)." : " (run `loginctl enable-linger` for boot-without-login)";
31
- return ok(`Installed and started systemd user service "${UNIT}".${note}`);
36
+ return ok(`Installed and started systemd user service "${unit}".${note}`);
32
37
  },
33
38
 
34
- async uninstall() {
35
- runSafe("systemctl", ["--user", "disable", "--now", UNIT]);
36
- rmSync(unitPath(), { force: true });
39
+ async uninstall(spec) {
40
+ const unit = unitName(spec);
41
+ runSafe("systemctl", ["--user", "disable", "--now", unit]);
42
+ rmSync(unitPath(spec), { force: true });
37
43
  runSafe("systemctl", ["--user", "daemon-reload"]);
38
- return ok(`Removed systemd user service "${UNIT}".`);
44
+ return ok(`Removed systemd user service "${unit}".`);
39
45
  },
40
46
 
41
- async start() {
42
- const r = runSafe("systemctl", ["--user", "start", UNIT]);
47
+ async start(spec) {
48
+ const r = runSafe("systemctl", ["--user", "start", unitName(spec)]);
43
49
  return r.ok ? ok("Started.") : fail(r.out);
44
50
  },
45
51
 
46
- async stop() {
47
- const r = runSafe("systemctl", ["--user", "stop", UNIT]);
52
+ async stop(spec) {
53
+ const r = runSafe("systemctl", ["--user", "stop", unitName(spec)]);
48
54
  return r.ok ? ok("Stopped.") : fail(r.out);
49
55
  },
50
56
 
51
- async status() {
52
- const r = runSafe("systemctl", ["--user", "status", UNIT, "--no-pager"]);
57
+ async status(spec) {
58
+ const r = runSafe("systemctl", ["--user", "status", unitName(spec), "--no-pager"]);
53
59
  return ok(r.out.trim() || "No status.");
54
60
  },
55
61
  };
@@ -10,8 +10,12 @@ import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
10
10
 
11
11
  const LABEL = "com.grok.telegrambot";
12
12
 
13
- function plistPath(): string {
14
- return join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
13
+ function labelOf(spec?: { macosLabel?: string }): string {
14
+ return spec?.macosLabel || LABEL;
15
+ }
16
+
17
+ function plistPath(spec?: { macosLabel?: string }): string {
18
+ return join(homedir(), "Library", "LaunchAgents", `${labelOf(spec)}.plist`);
15
19
  }
16
20
 
17
21
  export const macosController: ServiceController = {
@@ -20,32 +24,33 @@ export const macosController: ServiceController = {
20
24
  async install(spec) {
21
25
  mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
22
26
  mkdirSync(spec.logsDir, { recursive: true });
23
- const path = plistPath();
27
+ const path = plistPath(spec);
24
28
  runSafe("launchctl", ["unload", "-w", path]); // ignore if not loaded
25
29
  writeFileSync(path, plist(spec), "utf-8");
26
30
  const r = runSafe("launchctl", ["load", "-w", path]);
27
- return r.ok ? ok(`Installed and loaded LaunchAgent "${LABEL}".`) : fail(r.out);
31
+ return r.ok ? ok(`Installed and loaded LaunchAgent "${labelOf(spec)}".`) : fail(r.out);
28
32
  },
29
33
 
30
- async uninstall() {
31
- runSafe("launchctl", ["unload", "-w", plistPath()]);
32
- rmSync(plistPath(), { force: true });
33
- return ok(`Removed LaunchAgent "${LABEL}".`);
34
+ async uninstall(spec) {
35
+ runSafe("launchctl", ["unload", "-w", plistPath(spec)]);
36
+ rmSync(plistPath(spec), { force: true });
37
+ return ok(`Removed LaunchAgent "${labelOf(spec)}".`);
34
38
  },
35
39
 
36
- async start() {
37
- const r = runSafe("launchctl", ["start", LABEL]);
40
+ async start(spec) {
41
+ const r = runSafe("launchctl", ["start", labelOf(spec)]);
38
42
  return r.ok ? ok("Started.") : fail(r.out);
39
43
  },
40
44
 
41
- async stop() {
42
- const r = runSafe("launchctl", ["stop", LABEL]);
45
+ async stop(spec) {
46
+ const r = runSafe("launchctl", ["stop", labelOf(spec)]);
43
47
  return r.ok ? ok("Stopped.") : fail(r.out);
44
48
  },
45
49
 
46
- async status() {
50
+ async status(spec) {
51
+ const lab = labelOf(spec);
47
52
  const r = runSafe("launchctl", ["list"]);
48
- const line = r.out.split("\n").find((l) => l.includes(LABEL));
53
+ const line = r.out.split("\n").find((l) => l.includes(lab));
49
54
  return ok(line ? `Loaded: ${line.trim()}` : "Not loaded.");
50
55
  },
51
56
  };
@@ -67,7 +72,7 @@ function plist(spec: LaunchSpec): string {
67
72
  '<plist version="1.0">',
68
73
  "<dict>",
69
74
  " <key>Label</key>",
70
- ` <string>${LABEL}</string>`,
75
+ ` <string>${labelOf(spec)}</string>`,
71
76
  " <key>ProgramArguments</key>",
72
77
  " <array>",
73
78
  args,