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.
package/src/bot/bot.ts CHANGED
@@ -44,6 +44,9 @@ import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
44
44
  import { registerUsage } from "./handlers/usage.js";
45
45
  import { registerVoice } from "./handlers/voice.js";
46
46
  import { registerForum } from "./handlers/forum.js";
47
+ import { registerGrokSlash } from "./handlers/grok-slash.js";
48
+ import { AskUserService } from "./ask-user-service.js";
49
+ import { PlanExitService } from "./plan-exit-service.js";
47
50
  import { StatusPanel } from "./menu/status-panel.js";
48
51
  import { sendMarkdownDoc } from "./telegram-io.js";
49
52
  import { Ephemeral } from "./menu/ephemeral.js";
@@ -207,6 +210,18 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
207
210
  onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
208
211
  });
209
212
  acp.permissionHandler = (p) => permissions.handle(p);
213
+
214
+ const planExit = new PlanExitService(bot.api, registry, cfg.autoApprovePlan, (chatId) =>
215
+ statusPanel.ensurePinned(chatId),
216
+ );
217
+ acp.planExitHandler = (params) => planExit.handle(params);
218
+
219
+ const askUser = new AskUserService(
220
+ bot.api,
221
+ registry,
222
+ cfg.autoApprovePlan && cfg.autoApprovePermissions,
223
+ );
224
+ acp.askUserHandler = (params) => askUser.handle(params);
210
225
  // /stop and /cancel must cancel pending interactive permissions for that
211
226
  // session only (ACP requires cancelled outcomes) — never kill the agent.
212
227
  acp.onSessionCancel = (sessionId) => {
@@ -252,6 +267,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
252
267
  if (sid) await switchAndShow(ctx, deps, sid);
253
268
  });
254
269
 
270
+ bot.callbackQuery(/^planx:(\d+):(ok|chg|no)$/, async (ctx) => {
271
+ const toast = planExit.resolveChoice(ctx.match![1]!, ctx.match![2]!);
272
+ await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
273
+ });
274
+
275
+ bot.callbackQuery(/^asku:(\d+):(opt|next|prev|skip)(?::(.*))?$/, async (ctx) => {
276
+ const toast = askUser.tap(ctx.match![1]!, ctx.match![2]!, ctx.match![3]);
277
+ await ctx.answerCallbackQuery({ text: toast ?? "Expired" });
278
+ });
279
+
255
280
  // Legacy complexity buttons (removed — agent decides; auto-plan if complex).
256
281
  bot.callbackQuery(/^cplx:(simple|complex)$/, async (ctx) => {
257
282
  await ctx.answerCallbackQuery({ text: "Complexity is automatic now" });
@@ -349,6 +374,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
349
374
 
350
375
  registerMenu(bot, deps); // persistent-keyboard buttons (hears)
351
376
  registerWizardInput(bot, deps); // wizard text input (before commands)
377
+ bot.on("message:text", async (ctx, next) => {
378
+ const text = ctx.message?.text ?? "";
379
+ if (!text || text.startsWith("/")) return next();
380
+ if (planExit.takeFeedback(ctx.chat.id, text)) return;
381
+ await next();
382
+ });
352
383
  if (forum) registerForum(bot, deps, forum);
353
384
  registerControl(bot, deps);
354
385
  registerProjects(bot, deps);
@@ -367,6 +398,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
367
398
  registerPhotos(bot, deps); // photos & image documents
368
399
  registerDocuments(bot, deps); // non-image files (text inlined, binaries saved)
369
400
  registerVoice(bot, deps); // voice / audio -> transcription -> prompt
401
+ registerGrokSlash(bot, deps); // Grok Build /goal /plan /compact … + catch-all
370
402
  registerMessages(bot, deps); // catch-all text prompt — keep last
371
403
 
372
404
  bot.catch((err) => {
@@ -5,8 +5,10 @@
5
5
  * a shorter list with cancel/menu first — reply keyboards are unreliable there.
6
6
  */
7
7
 
8
- /** Full command menu (private chats + default). Order = Telegram "/" menu order. */
9
- export const COMMANDS: { command: string; description: string }[] = [
8
+ import { GROK_FORWARDED_COMMANDS } from "./handlers/grok-slash.js";
9
+
10
+ /** Bot-local commands (not forwarded to Grok). Order = Telegram "/" menu order. */
11
+ export const BOT_COMMANDS: { command: string; description: string }[] = [
10
12
  // Core control
11
13
  { command: "start", description: "Welcome, menu & status panel" },
12
14
  { command: "menu", description: "Open the menu" },
@@ -37,10 +39,17 @@ export const COMMANDS: { command: string; description: string }[] = [
37
39
  { command: "killall", description: "Kill all active sessions on the PC" },
38
40
  { command: "model", description: "Switch model: /model <id>" },
39
41
  { command: "restart", description: "Restart the Grok agent" },
42
+ { command: "sandbox", description: "Show / set Grok sandbox profile" },
40
43
  { command: "unwatch", description: "Stop following a live session" },
41
44
  { command: "help", description: "Show help" },
42
45
  ];
43
46
 
47
+ /** Full Telegram menu: bot-local + Grok Build shell forwards (≤100). */
48
+ export const COMMANDS: { command: string; description: string }[] = [
49
+ ...BOT_COMMANDS,
50
+ ...GROK_FORWARDED_COMMANDS.map(({ command, description }) => ({ command, description })),
51
+ ];
52
+
44
53
  /**
45
54
  * Group / forum command menu — keep short; cancel & menu first so topics can
46
55
  * stop a turn without the private reply-keyboard bar.
@@ -56,6 +65,9 @@ export const GROUP_COMMANDS: { command: string; description: string }[] = [
56
65
  { command: "btw", description: "Queue or run: /btw <text>" },
57
66
  { command: "flush", description: "Run queued follow-ups now" },
58
67
  { command: "model", description: "Switch model: /model <id>" },
68
+ { command: "goal", description: "Grok /goal — run until done" },
69
+ { command: "plan", description: "Grok /plan — enter plan mode" },
70
+ { command: "compact", description: "Grok /compact — compress context" },
59
71
  { command: "help", description: "Show help" },
60
72
  ];
61
73
 
@@ -91,4 +103,12 @@ export const HELP_TEXT = [
91
103
  "/flush \u2014 run queued follow-ups immediately",
92
104
  "/reauth \u2014 sign in to Grok",
93
105
  "/accounts \u2014 switch saved Grok accounts",
106
+ "/sandbox \u2014 show or set GROK_SANDBOX (needs /restart)",
107
+ "",
108
+ "GROK BUILD SLASH (forwarded into the active session)",
109
+ "/goal /plan /view_plan /compact /context /session_info",
110
+ "/deep_research /workflow /workflows /loop",
111
+ "/remember /memory /memory_flush /dream",
112
+ "Underscores map to hyphens (e.g. /view_plan \u2192 /view-plan).",
113
+ "Other non-bot Grok /commands and skills are also forwarded.",
94
114
  ].join("\n");
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Forward Grok Build slash commands (e.g. /goal, /plan, /compact) into the
3
+ * active ACP session. Grok shell builtins are parsed from the prompt text by
4
+ * slash_exec — they never reached the agent before because the Telegram message
5
+ * handler treated unknown "/…" lines as typos.
6
+ *
7
+ * Bot-local commands (projects, sessions, reauth, …) stay reserved and are
8
+ * handled by their own `bot.command` registrations. Name collisions use
9
+ * non-colliding Telegram aliases that still send the correct Grok slash line.
10
+ */
11
+ import type { Bot, Context } from "grammy";
12
+ import { textPrompt } from "../../app/types.js";
13
+ import { createLogger } from "../../logger.js";
14
+ import { isGeneralThread } from "../../forum/thread.js";
15
+ import type { BotDeps } from "../deps.js";
16
+ import { extractReplyContext } from "../reply-context.js";
17
+ import { resolveScope } from "../scope.js";
18
+
19
+ const log = createLogger("grok-slash");
20
+
21
+ /**
22
+ * Telegram command names reserved by this bot (without leading slash).
23
+ * Must stay in sync with bot.command registrations / COMMANDS menu.
24
+ * Bare names in this set are NEVER stolen for Grok — use collision aliases.
25
+ */
26
+ export const BOT_RESERVED_COMMANDS = new Set(
27
+ [
28
+ "start",
29
+ "menu",
30
+ "help",
31
+ "projects",
32
+ "project",
33
+ "sessions",
34
+ "active",
35
+ "running",
36
+ "killall",
37
+ "mcp",
38
+ "tasks",
39
+ "newtask",
40
+ "history",
41
+ "new",
42
+ "status",
43
+ "usage",
44
+ "btw",
45
+ "flush",
46
+ "queue",
47
+ "clearqueue",
48
+ "cancel",
49
+ "stop",
50
+ "unwatch",
51
+ "model",
52
+ "restart",
53
+ "sandbox",
54
+ "reauth",
55
+ "accounts",
56
+ "import",
57
+ "forum_setup",
58
+ ].map((c) => c.toLowerCase()),
59
+ );
60
+
61
+ /**
62
+ * Telegram command name (no slash, lowercase) → Grok shell command name (no slash).
63
+ * Covers hyphenated multi-word forms, documented Grok aliases, and collision aliases
64
+ * for bot-reserved bare names that still have distinct Grok builtins.
65
+ */
66
+ export const GROK_SLASH_ALIASES: Readonly<Record<string, string>> = {
67
+ // Multi-word (Telegram underscore ↔ Grok hyphen)
68
+ view_plan: "view-plan",
69
+ show_plan: "view-plan",
70
+ plan_view: "view-plan",
71
+ deep_research: "deep-research",
72
+ always_approve: "always-approve",
73
+ session_info: "session-info",
74
+ imagine_video: "imagine-video",
75
+ config_agents: "config-agents",
76
+ release_notes: "release-notes",
77
+ import_claude: "import-claude",
78
+ compact_mode: "compact-mode",
79
+ vim_mode: "vim-mode",
80
+ agents_dashboard: "dashboard",
81
+ // No-underscore convenience forms
82
+ viewplan: "view-plan",
83
+ showplan: "view-plan",
84
+ planview: "view-plan",
85
+ deepresearch: "deep-research",
86
+ alwaysapprove: "always-approve",
87
+ sessioninfo: "session-info",
88
+ imaginevideo: "imagine-video",
89
+ configagents: "config-agents",
90
+ releasenotes: "release-notes",
91
+ importclaude: "import-claude",
92
+ // Documented Grok aliases
93
+ clear: "new",
94
+ undo: "rewind",
95
+ title: "rename",
96
+ m: "model",
97
+ mem: "memory",
98
+ cost: "usage",
99
+ agents: "config-agents",
100
+ howto: "docs",
101
+ guides: "docs",
102
+ changelog: "release-notes",
103
+ ml: "multiline",
104
+ t: "theme",
105
+ full: "fullscreen",
106
+ tour: "tutorial",
107
+ onboarding: "tutorial",
108
+ welcome: "home",
109
+ exit: "quit",
110
+ prefs: "settings",
111
+ preferences: "settings",
112
+ config: "settings",
113
+ terminal_setup: "doctor",
114
+ terminal_check: "doctor",
115
+ terminal_info: "doctor",
116
+ terminalsetup: "doctor",
117
+ terminalcheck: "doctor",
118
+ terminalinfo: "doctor",
119
+ // Bot name collisions → still send the Grok builtin
120
+ grok_new: "new",
121
+ session_new: "new",
122
+ grok_clear: "new",
123
+ memory_flush: "flush",
124
+ grok_flush: "flush",
125
+ grok_usage: "usage",
126
+ grok_cost: "usage",
127
+ grok_btw: "btw",
128
+ // session-info aliases when bare status/info are bot-reserved
129
+ grok_status: "session-info",
130
+ grok_info: "session-info",
131
+ };
132
+
133
+ /**
134
+ * Official shell builtins that are meaningful over ACP (not pure TUI/pager).
135
+ * Used for tests / inventory; menu may advertise a subset + collision aliases.
136
+ */
137
+ export const GROK_SHELL_ACP_COMMANDS: readonly string[] = [
138
+ "new",
139
+ "compact",
140
+ "context",
141
+ "session-info",
142
+ "fork",
143
+ "rewind",
144
+ "copy",
145
+ "export",
146
+ "delete",
147
+ "rename",
148
+ "model",
149
+ "effort",
150
+ "always-approve",
151
+ "auto",
152
+ "plan",
153
+ "view-plan",
154
+ "memory",
155
+ "flush",
156
+ "dream",
157
+ "remember",
158
+ "hooks",
159
+ "plugins",
160
+ "marketplace",
161
+ "skills",
162
+ "imagine",
163
+ "imagine-video",
164
+ "loop",
165
+ "goal",
166
+ "deep-research",
167
+ "workflow",
168
+ "workflows",
169
+ "feedback",
170
+ "btw",
171
+ "mcps",
172
+ "doctor",
173
+ "release-notes",
174
+ "docs",
175
+ "import-claude",
176
+ "config-agents",
177
+ "personas",
178
+ "login",
179
+ "logout",
180
+ "usage",
181
+ "privacy",
182
+ "settings",
183
+ ];
184
+
185
+ /** Grok Build slash commands we advertise in the Telegram menu (Telegram-safe names). */
186
+ export const GROK_FORWARDED_COMMANDS: { command: string; description: string; grok: string }[] = [
187
+ // Session
188
+ { command: "compact", description: "Grok /compact — compress context", grok: "compact" },
189
+ { command: "context", description: "Grok /context — context window usage", grok: "context" },
190
+ { command: "session_info", description: "Grok /session-info", grok: "session-info" },
191
+ { command: "fork", description: "Grok /fork — branch session", grok: "fork" },
192
+ { command: "rewind", description: "Grok /rewind — undo last turns", grok: "rewind" },
193
+ { command: "copy", description: "Grok /copy — copy last response", grok: "copy" },
194
+ { command: "export", description: "Grok /export — export conversation", grok: "export" },
195
+ { command: "delete", description: "Grok /delete — delete session history", grok: "delete" },
196
+ { command: "rename", description: "Grok /rename <title>", grok: "rename" },
197
+ { command: "grok_new", description: "Grok /new — fresh session (CLI)", grok: "new" },
198
+ // Model / mode
199
+ { command: "effort", description: "Grok /effort low|medium|high|xhigh", grok: "effort" },
200
+ { command: "always_approve", description: "Grok /always-approve toggle", grok: "always-approve" },
201
+ { command: "auto", description: "Grok /auto — auto permission mode", grok: "auto" },
202
+ { command: "plan", description: "Grok /plan — enter plan mode", grok: "plan" },
203
+ { command: "view_plan", description: "Grok /view-plan — show saved plan", grok: "view-plan" },
204
+ // Memory
205
+ { command: "memory", description: "Grok /memory — browse memories", grok: "memory" },
206
+ { command: "memory_flush", description: "Grok /flush — save session to memory", grok: "flush" },
207
+ { command: "dream", description: "Grok /dream — consolidate memory", grok: "dream" },
208
+ { command: "remember", description: "Grok /remember <note>", grok: "remember" },
209
+ // Extensions
210
+ { command: "hooks", description: "Grok /hooks — hooks panel", grok: "hooks" },
211
+ { command: "plugins", description: "Grok /plugins — plugins panel", grok: "plugins" },
212
+ { command: "marketplace", description: "Grok /marketplace — plugin marketplace", grok: "marketplace" },
213
+ { command: "skills", description: "Grok /skills — skills panel", grok: "skills" },
214
+ // Media
215
+ { command: "imagine", description: "Grok /imagine <description>", grok: "imagine" },
216
+ { command: "imagine_video", description: "Grok /imagine-video <description>", grok: "imagine-video" },
217
+ // Scheduling / workflows / goals
218
+ { command: "loop", description: "Grok /loop [interval] <prompt>", grok: "loop" },
219
+ { command: "goal", description: "Grok /goal — set/status/pause/resume/clear", grok: "goal" },
220
+ { command: "deep_research", description: "Grok /deep-research <query>", grok: "deep-research" },
221
+ { command: "workflow", description: "Grok /workflow — run/manage workflow", grok: "workflow" },
222
+ { command: "workflows", description: "Grok /workflows — workflow dashboard", grok: "workflows" },
223
+ // Other
224
+ { command: "feedback", description: "Grok /feedback [message]", grok: "feedback" },
225
+ { command: "grok_btw", description: "Grok /btw — aside without interrupting", grok: "btw" },
226
+ { command: "mcps", description: "Grok /mcps — MCP servers modal", grok: "mcps" },
227
+ { command: "doctor", description: "Grok /doctor — session diagnostics", grok: "doctor" },
228
+ { command: "release_notes", description: "Grok /release-notes", grok: "release-notes" },
229
+ { command: "docs", description: "Grok /docs — how-to guides", grok: "docs" },
230
+ { command: "import_claude", description: "Grok /import-claude", grok: "import-claude" },
231
+ { command: "config_agents", description: "Grok /config-agents — agent defs", grok: "config-agents" },
232
+ { command: "personas", description: "Grok /personas — manage personas", grok: "personas" },
233
+ { command: "login", description: "Grok /login — re-auth in session", grok: "login" },
234
+ { command: "logout", description: "Grok /logout", grok: "logout" },
235
+ { command: "grok_usage", description: "Grok /usage — credit/billing", grok: "usage" },
236
+ { command: "privacy", description: "Grok /privacy — data/retention", grok: "privacy" },
237
+ { command: "settings", description: "Grok /settings — config modal", grok: "settings" },
238
+ ];
239
+
240
+ /** Resolve a Telegram command token (no slash) to a Grok shell command name. */
241
+ export function resolveGrokCommandName(telegramName: string): string {
242
+ const name = telegramName.toLowerCase();
243
+ const fromAlias = GROK_SLASH_ALIASES[name];
244
+ if (fromAlias) return fromAlias;
245
+ const fromMenu = GROK_FORWARDED_COMMANDS.find((c) => c.command === name);
246
+ if (fromMenu) return fromMenu.grok;
247
+ // underscore → hyphen for multi-word Grok commands (deep_research → deep-research)
248
+ return name.replace(/_/g, "-");
249
+ }
250
+
251
+ /** True when a bare slash line should be forwarded to Grok (not a bot command). */
252
+ export function shouldForwardSlashToGrok(text: string): boolean {
253
+ const t = text.trim();
254
+ if (!t.startsWith("/") || t.includes("\n")) return false;
255
+ // /cmd@botname args
256
+ const m = t.match(/^\/([A-Za-z0-9_]+)(?:@\w+)?(?:\s|$)/);
257
+ if (!m) return false;
258
+ const name = m[1]!.toLowerCase();
259
+ if (BOT_RESERVED_COMMANDS.has(name)) return false;
260
+ return true;
261
+ }
262
+
263
+ /** Normalize Telegram `/view_plan foo` → Grok `/view-plan foo`. */
264
+ export function toGrokSlashLine(text: string): string {
265
+ const t = text.trim();
266
+ const m = t.match(/^\/([A-Za-z0-9_]+)(@\w+)?([\s\S]*)$/);
267
+ if (!m) return t;
268
+ const rest = m[3] ?? "";
269
+ const name = resolveGrokCommandName(m[1]!);
270
+ return `/${name}${rest}`;
271
+ }
272
+
273
+ export async function submitGrokSlash(ctx: Context, deps: BotDeps, line: string): Promise<void> {
274
+ if (!ctx.chat) return;
275
+ const grokLine = toGrokSlashLine(line);
276
+ const scope = resolveScope(ctx, deps);
277
+ const extra: Record<string, unknown> = { parse_mode: "Markdown", ...scope.threadExtra };
278
+ if (scope.isForum && isGeneralThread(scope.threadId) && /^\/goal(?:\s|$)/i.test(grokLine)) {
279
+ await ctx.reply(
280
+ "Use /goal in a **project topic** or **AI Chat**, not in General \\(manager\\).",
281
+ extra,
282
+ );
283
+ return;
284
+ }
285
+ const rt = scope.rt;
286
+ try {
287
+ // Prefer dedicated ACP command RPC when the agent supports it; fall back to
288
+ // session/prompt (Grok slash_exec parses leading / in the prompt).
289
+ if (rt.sessionId) {
290
+ try {
291
+ await deps.acp.executeCommand(rt.sessionId, grokLine);
292
+ await ctx.reply(`\u25B6\uFE0F Sent to Grok: \`${grokLine}\``, extra);
293
+ return;
294
+ } catch (err) {
295
+ log.debug(
296
+ `executeCommand failed for ${grokLine}: ${(err as Error).message}; falling back to prompt`,
297
+ );
298
+ }
299
+ }
300
+ const outcome = await rt.submit(
301
+ textPrompt(grokLine, ctx.message?.message_id, extractReplyContext(ctx), {
302
+ skipSelfRecheck: true,
303
+ rawSlashCommand: true,
304
+ }),
305
+ );
306
+ if (outcome === "queued") {
307
+ await ctx.reply(
308
+ `\u{1F4E5} Queued (position ${rt.queueLength}): \`${grokLine}\` \u2014 runs after the current turn.`,
309
+ extra,
310
+ );
311
+ } else {
312
+ await ctx.reply(`\u25B6\uFE0F Running \`${grokLine}\`\u2026`, extra);
313
+ }
314
+ } catch (err) {
315
+ log.warn(`grok slash failed: ${(err as Error).message}`);
316
+ await ctx.reply(`\u274C Could not run \`${grokLine}\`: ${(err as Error).message}`, extra);
317
+ }
318
+ }
319
+
320
+ export function registerGrokSlash(bot: Bot, deps: BotDeps): void {
321
+ // Explicit Telegram commands for advertised Grok builtins (appear in menu).
322
+ for (const def of GROK_FORWARDED_COMMANDS) {
323
+ if (BOT_RESERVED_COMMANDS.has(def.command)) continue;
324
+ bot.command(def.command, async (ctx) => {
325
+ const args = (ctx.match || "").toString();
326
+ const line = args ? `/${def.command} ${args}` : `/${def.command}`;
327
+ await submitGrokSlash(ctx, deps, line);
328
+ });
329
+ }
330
+ // Catch-all for other Grok / skills slashes not in the menu.
331
+ bot.on("message:text", async (ctx, next) => {
332
+ const text = ctx.message?.text ?? "";
333
+ if (!shouldForwardSlashToGrok(text)) return next();
334
+ await submitGrokSlash(ctx, deps, text);
335
+ });
336
+ }
@@ -1,9 +1,14 @@
1
1
  /**
2
- * System commands: /queue /clearqueue /model /restart.
2
+ * System commands: /queue /clearqueue /model /restart /sandbox.
3
3
  */
4
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
4
5
  import type { Bot } from "grammy";
6
+ import { InlineKeyboard } from "grammy";
7
+ import { ENV_PATH } from "../../config.js";
5
8
  import type { BotDeps } from "../deps.js";
6
9
 
10
+ const SANDBOX_PROFILES = ["workspace-safe", "workspace", "strict", "read-only", "off"] as const;
11
+
7
12
  export function registerSystem(bot: Bot, deps: BotDeps): void {
8
13
  bot.command("queue", async (ctx) => {
9
14
  const rt = deps.registry.get(ctx.chat.id);
@@ -48,4 +53,61 @@ export function registerSystem(bot: Bot, deps: BotDeps): void {
48
53
  await ctx.reply(`\u274C Restart failed: ${(err as Error).message}`);
49
54
  }
50
55
  });
56
+
57
+ bot.command("sandbox", async (ctx) => {
58
+ const arg = (ctx.match || "").toString().trim();
59
+ const current = deps.cfg.sandboxProfile || process.env.GROK_SANDBOX || "(from ~/.grok/config.toml)";
60
+ if (!arg) {
61
+ const kb = new InlineKeyboard();
62
+ for (const p of SANDBOX_PROFILES) kb.text(p, `sbx:${p}`).row();
63
+ await ctx.reply(
64
+ `\u{1F6E1} Sandbox profile now: ${current}\n` +
65
+ `Grok reads GROK_SANDBOX. Pick one, then /restart.\n` +
66
+ `Or: /sandbox workspace-safe`,
67
+ { reply_markup: kb },
68
+ );
69
+ return;
70
+ }
71
+ if (!(SANDBOX_PROFILES as readonly string[]).includes(arg)) {
72
+ await ctx.reply(`Unknown profile. Use: ${SANDBOX_PROFILES.join(", ")}`);
73
+ return;
74
+ }
75
+ try {
76
+ upsertEnv("GROK_SANDBOX", arg);
77
+ deps.cfg.sandboxProfile = arg;
78
+ process.env.GROK_SANDBOX = arg;
79
+ deps.acp.setAgentOptions({ sandboxProfile: arg });
80
+ await ctx.reply(`\u2705 GROK_SANDBOX=${arg} written. Send /restart to apply.`);
81
+ } catch (e) {
82
+ await ctx.reply(`\u274C Could not write .env: ${(e as Error).message}`);
83
+ }
84
+ });
85
+
86
+ bot.callbackQuery(/^sbx:([\w-]+)$/, async (ctx) => {
87
+ const profile = ctx.match![1]!;
88
+ if (!(SANDBOX_PROFILES as readonly string[]).includes(profile)) {
89
+ await ctx.answerCallbackQuery({ text: "Unknown profile" });
90
+ return;
91
+ }
92
+ try {
93
+ upsertEnv("GROK_SANDBOX", profile);
94
+ deps.cfg.sandboxProfile = profile;
95
+ process.env.GROK_SANDBOX = profile;
96
+ deps.acp.setAgentOptions({ sandboxProfile: profile });
97
+ await ctx.answerCallbackQuery({ text: profile });
98
+ await ctx.editMessageText(`\u2705 GROK_SANDBOX=${profile}. Send /restart to apply.`).catch(() => {});
99
+ } catch (e) {
100
+ await ctx.answerCallbackQuery({ text: (e as Error).message.slice(0, 40) });
101
+ }
102
+ });
103
+ }
104
+
105
+ /** Set KEY=value in the instance .env without dumping secrets. */
106
+ function upsertEnv(key: string, value: string): void {
107
+ if (!existsSync(ENV_PATH)) throw new Error(".env not found");
108
+ let body = readFileSync(ENV_PATH, "utf-8");
109
+ const re = new RegExp(`^${key}=.*$`, "m");
110
+ if (re.test(body)) body = body.replace(re, `${key}=${value}`);
111
+ else body = body.replace(/\s*$/, `\n${key}=${value}\n`);
112
+ writeFileSync(ENV_PATH, body, "utf-8");
51
113
  }