grok-telegram-bot 2.5.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.
Files changed (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. package/src/stream/streamer.ts +46 -10
@@ -25,11 +25,25 @@ export const TELEGRAM_SEND_PROMPT_MAX = 5;
25
25
  export type TelegramAction =
26
26
  | { action: "create_topic"; name: string; path?: string }
27
27
  | { action: "set_path"; topic: string; path: string }
28
- | { action: "send_prompt"; topic: string; prompt: string; newSession?: boolean }
28
+ | {
29
+ action: "send_prompt";
30
+ topic: string;
31
+ prompt: string;
32
+ newSession?: boolean;
33
+ /** Resume this exact Grok session in the topic (full id or short prefix). */
34
+ sessionId?: string;
35
+ }
36
+ /** User-facing message in the current chat (General: only way to talk to the user). */
37
+ | { action: "notify"; text: string; important?: boolean }
29
38
  | { action: "search_memory"; query: string; limit?: number }
39
+ | { action: "list_topics" }
40
+ | { action: "list_jobs" }
30
41
  | { action: "list_bots" }
31
42
  | { action: "bot_command"; bot: string; command: string; args?: string };
32
43
 
44
+ /** Max user-facing notify messages per turn (anti-spam). */
45
+ export const TELEGRAM_NOTIFY_MAX = 3;
46
+
33
47
  export interface TelegramActionExtract {
34
48
  actions: TelegramAction[];
35
49
  /** Text with telegram action fences removed. */
@@ -49,8 +63,11 @@ export const TELEGRAM_BRIDGE_DIRECTIVE_BASE = [
49
63
  '{ "telegram": [',
50
64
  ' { "action": "create_topic", "name": "Topic title", "path": "optional absolute project path or exact catalog name" },',
51
65
  ' { "action": "set_path", "topic": "Topic title or #threadId", "path": "absolute path or exact catalog name" },',
52
- ' { "action": "send_prompt", "topic": "Topic title or #threadId", "prompt": "work for that topic", "new_session": false },',
66
+ ' { "action": "send_prompt", "topic": "ExactTopicTitle or #threadId", "prompt": "work for that topic", "new_session": false, "session_id": "optional-full-or-short-session-id" },',
67
+ ' { "action": "notify", "text": "short message for the user", "important": false },',
53
68
  ' { "action": "search_memory", "query": "keywords about past work", "limit": 8 },',
69
+ ' { "action": "list_topics" },',
70
+ ' { "action": "list_jobs" },',
54
71
  ' { "action": "list_bots" },',
55
72
  ' { "action": "bot_command", "bot": "username_without_at", "command": "status", "args": "optional" }',
56
73
  "] }",
@@ -62,22 +79,50 @@ export const TELEGRAM_BRIDGE_DIRECTIVE_BASE = [
62
79
  "- set_path — bind/rebind an existing topic to a project path (absolute dir or exact catalog name).",
63
80
  " Absolute paths that do not exist yet are created on disk.",
64
81
  "- send_prompt — start or queue a prompt in another topic's session (does not wait for that turn to finish).",
65
- " From General/AI Chat (GROK_WORKSPACE) you can create a project topic, set_path, then send_prompt with multi-step work.",
66
- " topic = exact topic title, #threadId, \"general\", or \"ai chat\". Optional new_session=true starts a fresh session there.",
67
- "- search_memory — search indexed forum topics + session titles/comments/history.",
82
+ " From General (manager) you MUST create/set path then send_prompt — never code in General.",
83
+ " Child sessions started from General auto report back with a MANAGER WORK REPORT when done.",
84
+ " topic = EXACT forum title (from list_topics / memory), #threadId, \"general\", or \"ai chat\".",
85
+ " NEVER use placeholders like \"…\", \"...\", \"topic\", \"there\" — call list_topics if unsure.",
86
+ " Optional new_session=true starts a FRESH session in that topic.",
87
+ " Optional session_id (full UUID or short prefix like 019fc9ec) RESUMES that exact session.",
88
+ " When session_id is set, topic may be omitted — the bridge maps session path → topic.",
89
+ " session_id is REQUIRED when memory found a related session and the user wants a follow-up there.",
90
+ " Without session_id the bridge uses the topic's current foreground session (often wrong).",
91
+ "- notify — optional extra short message. In General the user ALSO sees your free-form prose,",
92
+ " so always write a brief chat reply; do not stay silent after search/dispatch.",
93
+ " Max 3 notify per turn. Never notify dispatch chatter, job tables, or \"sending to…\" spam.",
94
+ "- search_memory — search indexed forum topics + session titles/comments/history (memory-first).",
95
+ "- list_topics — list all mapped forum topics (name, #id, path, kind).",
96
+ "- list_jobs — list recent manager dispatches from General (status, target topic).",
68
97
  "- list_bots — list allowlisted sibling Telegram bots and command catalogs.",
69
98
  "- bot_command — /command@bot; waits for that bot to settle. NOT a Done; timeouts return ok=false.",
70
99
  "",
100
+ "Example (General — short prose plus dispatch):",
101
+ "```json",
102
+ '{ "telegram": [',
103
+ ' { "action": "send_prompt", "topic": "WindowsStoreListingGenerator", "session_id": "019fc9ec", "prompt": "Continue ship gate." },',
104
+ ' { "action": "notify", "text": "On it — continuing the ship gate in WindowsStoreListingGenerator." }',
105
+ "] }",
106
+ "```",
107
+ "",
108
+ "Example (session_id only — topic inferred from session path):",
109
+ "```json",
110
+ '{ "telegram": [',
111
+ ' { "action": "send_prompt", "session_id": "019fc9ec", "prompt": "Continue the related work." }',
112
+ "] }",
113
+ "```",
114
+ "",
71
115
  "Example (from General): create project topic + path + kick off work there:",
72
- '```json',
116
+ "```json",
73
117
  '{ "telegram": [',
74
118
  ' { "action": "create_topic", "name": "MyApp", "path": "H:\\\\Projects\\\\MyApp" },',
75
- ' { "action": "send_prompt", "topic": "MyApp", "prompt": "1) scaffold\\n2) tests\\n3) README" }',
119
+ ' { "action": "send_prompt", "topic": "MyApp", "new_session": true, "prompt": "1) scaffold\\n2) tests\\n3) README" },',
120
+ ' { "action": "notify", "text": "Created **MyApp** and started scaffolding there." }',
76
121
  "] }",
77
122
  "```",
78
123
  "",
79
124
  "After you emit these actions, the bridge runs them and may send TELEGRAM BRIDGE RESULTS. Use those facts; do not invent replies.",
80
- "The bridge strips the JSON fence from the user-visible message. Prefer plain prose for the user; put protocol only in the fence.",
125
+ "The bridge strips the JSON fence from display. In General write a short prose answer the user can see.",
81
126
  "Do not spam actions. Prefer one orchestration block per turn.",
82
127
  ].join("\n");
83
128
 
@@ -87,6 +132,8 @@ export interface TelegramBridgeCaps {
87
132
  allowedBots: string[];
88
133
  /** username → command list for first-prompt teaching */
89
134
  botCommands?: Record<string, Array<{ command: string; description?: string }>>;
135
+ /** When true, General manager wording is appended. */
136
+ managerMode?: boolean;
90
137
  }
91
138
 
92
139
  /** Full first-prompt directive including live capabilities. */
@@ -95,7 +142,7 @@ export function buildTelegramBridgeDirective(caps: TelegramBridgeCaps): string {
95
142
  if (caps.forumReady && caps.topicGroupId !== undefined) {
96
143
  lines.push(
97
144
  `- Forum topics: READY (group ${caps.topicGroupId}). You may create_topic, set_path, and send_prompt.`,
98
- `- General / AI Chat sessions use GROK_WORKSPACE; other topics use their bound project path.`,
145
+ `- General is the manager topic (orchestrates only). AI Chat uses GROK_WORKSPACE for coding there; project topics use their bound path.`,
99
146
  );
100
147
  } else if (caps.topicGroupId !== undefined) {
101
148
  lines.push(
@@ -123,7 +170,12 @@ export function buildTelegramBridgeDirective(caps: TelegramBridgeCaps): string {
123
170
  "- Sibling bots: none configured (ALLOWED_TELEGRAM_BOTS empty). list_bots returns empty; bot_command disabled.",
124
171
  );
125
172
  }
126
- lines.push("- search_memory: always available against bot-owned session/topic indexes.");
173
+ lines.push("- search_memory / list_topics / list_jobs: available against bot-owned indexes.");
174
+ if (caps.managerMode) {
175
+ lines.push(
176
+ "- You are in General manager mode: chat-like replies only; memory-first; dispatch via send_prompt; child work auto-reports back.",
177
+ );
178
+ }
127
179
  return lines.join("\n");
128
180
  }
129
181
 
@@ -165,6 +217,7 @@ export function buildTelegramBridgeResultsPrompt(results: unknown[]): string {
165
217
  payload,
166
218
  "```",
167
219
  "Continue the user's task with this information. Do not ask the user to paste results. Do not re-emit the same telegram actions unless something failed and a retry is useful.",
220
+ "If actions succeeded, continue with a short prose answer — never dump job tables or dispatch narration.",
168
221
  ].join("\n");
169
222
  }
170
223
 
@@ -190,7 +243,7 @@ export function extractTelegramActions(text: string): TelegramActionExtract {
190
243
  cleaned = cleaned.replace(/```(?:json|JSON)?\s*\r?\n[\s\S]*$/i, (tail) => {
191
244
  if (
192
245
  /"telegram"\s*:/i.test(tail) ||
193
- /"action"\s*:\s*"(?:create_topic|set_path|send_prompt|search_memory|list_bots|bot_command)"/i.test(
246
+ /"action"\s*:\s*"(?:create_topic|set_path|send_prompt|notify|search_memory|list_topics|list_jobs|list_bots|bot_command)"/i.test(
194
247
  tail,
195
248
  )
196
249
  ) {
@@ -280,19 +333,58 @@ function normalizeAction(item: unknown): TelegramAction | undefined {
280
333
  case "send_prompt":
281
334
  case "topic_prompt":
282
335
  case "prompt_topic": {
283
- const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
336
+ let topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
284
337
  const prompt = String(rec.prompt ?? rec.text ?? rec.message ?? "").trim();
285
- if (!topic || !prompt) return undefined;
286
338
  const newSessionRaw = rec.new_session ?? rec.newSession ?? rec.fresh;
287
339
  const newSession =
288
340
  newSessionRaw === true ||
289
341
  newSessionRaw === 1 ||
290
342
  /^(1|true|yes|y)$/i.test(String(newSessionRaw ?? "").trim());
343
+ const sessionIdRaw = String(
344
+ rec.session_id ?? rec.sessionId ?? rec.sess ?? rec.session ?? "",
345
+ ).trim();
346
+ const sessionId = sessionIdRaw
347
+ ? sessionIdRaw.replace(/^#?sess[_-]?/i, "").slice(0, 64)
348
+ : undefined;
349
+ // Drop placeholder topics ("…") so resolve can use session_id / memory inference.
350
+ if (
351
+ !topic ||
352
+ /^[\s.…·•⋯︙\-–—_*~`'"“”‘’\u2026\u22ef\u3002]+$/u.test(topic) ||
353
+ /^(topic|there|here|same|related|todo|tbd|none|null|target)$/i.test(topic)
354
+ ) {
355
+ topic = "";
356
+ }
357
+ // topic optional when session_id is present OR prompt alone (memory may infer topic).
358
+ if (!prompt) return undefined;
359
+ // Allow prompt-only send_prompt: bridge may infer topic from memory + user ask.
360
+ // Still require at least something actionable (prompt is enough).
361
+ // Full prompt for the target agent — do NOT hard-crop to Telegram size.
362
+ // Long prompts are split only in the human-visible announce message.
363
+ const PROMPT_MAX = 100_000;
291
364
  return {
292
365
  action: "send_prompt",
293
366
  topic: topic.slice(0, 128),
294
- prompt: prompt.slice(0, 4000),
367
+ prompt: prompt.length > PROMPT_MAX ? prompt.slice(0, PROMPT_MAX) : prompt,
295
368
  newSession: newSession || undefined,
369
+ // session_id wins over new_session when both are set (resume is explicit).
370
+ sessionId: sessionId || undefined,
371
+ };
372
+ }
373
+ case "notify":
374
+ case "message_user":
375
+ case "say":
376
+ case "tell_user": {
377
+ const text = String(rec.text ?? rec.message ?? rec.body ?? rec.prompt ?? "").trim();
378
+ if (!text) return undefined;
379
+ const impRaw = rec.important ?? rec.priority ?? rec.loud;
380
+ const important =
381
+ impRaw === true ||
382
+ impRaw === 1 ||
383
+ /^(1|true|yes|y|high|urgent|important)$/i.test(String(impRaw ?? "").trim());
384
+ return {
385
+ action: "notify",
386
+ text: text.slice(0, 3500),
387
+ important: important || undefined,
296
388
  };
297
389
  }
298
390
  case "search_memory":
@@ -305,6 +397,13 @@ function normalizeAction(item: unknown): TelegramAction | undefined {
305
397
  limit = Math.max(1, Math.min(20, Math.round(limit)));
306
398
  return { action: "search_memory", query: query.slice(0, 300), limit };
307
399
  }
400
+ case "list_topics":
401
+ case "topics":
402
+ return { action: "list_topics" };
403
+ case "list_jobs":
404
+ case "jobs":
405
+ case "manager_jobs":
406
+ return { action: "list_jobs" };
308
407
  case "list_bots":
309
408
  case "bots":
310
409
  return { action: "list_bots" };
@@ -332,6 +431,7 @@ function normalizeAction(item: unknown): TelegramAction | undefined {
332
431
  function capBotCommands(actions: TelegramAction[]): TelegramAction[] {
333
432
  let botCmds = 0;
334
433
  let sendPrompts = 0;
434
+ let notifies = 0;
335
435
  const out: TelegramAction[] = [];
336
436
  for (const a of actions) {
337
437
  if (a.action === "bot_command") {
@@ -342,6 +442,10 @@ function capBotCommands(actions: TelegramAction[]): TelegramAction[] {
342
442
  if (sendPrompts >= TELEGRAM_SEND_PROMPT_MAX) continue;
343
443
  sendPrompts++;
344
444
  }
445
+ if (a.action === "notify") {
446
+ if (notifies >= TELEGRAM_NOTIFY_MAX) continue;
447
+ notifies++;
448
+ }
345
449
  out.push(a);
346
450
  if (out.length >= TELEGRAM_ACTION_MAX) break;
347
451
  }
@@ -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,
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { execFileSync, spawn } from "node:child_process";
6
6
  import { join } from "node:path";
7
+ import { parseInstanceFlags, serviceIdentity } from "../app/instance.js";
7
8
  import { PROJECT_ROOT, INSTANCE_DIR } from "../config.js";
8
9
 
9
10
  export type Platform = "windows" | "linux" | "macos" | "unknown";
@@ -31,9 +32,14 @@ export function buildLaunchSpec(): LaunchSpec {
31
32
  // the bot where its .env/logs/data live. Only appended for a global install
32
33
  // (instance dir differs) so in-place checkouts keep identical launch args.
33
34
  if (INSTANCE_DIR !== PROJECT_ROOT) args.push("--instance", INSTANCE_DIR);
35
+ const flags = parseInstanceFlags(process.argv);
36
+ const ident = serviceIdentity(INSTANCE_DIR, flags.name || process.env.GROK_TG_NAME);
34
37
  return {
35
- id: "grok-telegram-bot",
36
- displayName: "Grok Telegram Bot",
38
+ id: ident.id,
39
+ displayName: ident.displayName,
40
+ windowsTaskName: ident.windowsTaskName,
41
+ macosLabel: ident.macosLabel,
42
+ slug: ident.slug,
37
43
  nodePath: process.execPath,
38
44
  args,
39
45
  cwd: PROJECT_ROOT,
@@ -41,7 +47,10 @@ export function buildLaunchSpec(): LaunchSpec {
41
47
  // relaunches on exit — so its auto-updater exits cleanly instead of
42
48
  // re-exec'ing (which would double-run). Windows applies no env, so its
43
49
  // Scheduled Task (no auto-restart) takes the re-exec path instead.
44
- env: { GROK_TG_SUPERVISED: "1" },
50
+ env: {
51
+ GROK_TG_SUPERVISED: "1",
52
+ ...(ident.slug ? { GROK_TG_NAME: ident.slug } : {}),
53
+ },
45
54
  logsDir,
46
55
  logFile: join(logsDir, "grok-telegram-bot.log"),
47
56
  };
@@ -7,6 +7,12 @@ export interface LaunchSpec {
7
7
  id: string;
8
8
  /** Human-readable name. */
9
9
  displayName: string;
10
+ /** Windows Scheduled Task name (unique per named instance). */
11
+ windowsTaskName?: string;
12
+ /** launchd label (unique per named instance). */
13
+ macosLabel?: string;
14
+ /** Named instance slug when using `--name`. */
15
+ slug?: string;
10
16
  /** Absolute path to the node binary that should run the bot. */
11
17
  nodePath: string;
12
18
  /** Arguments after the node binary (tsx loader + entry file). */
@@ -15,6 +15,14 @@ const TASK = "GrokTelegramBot";
15
15
  /** Launcher dropped in the per-user Startup folder when no admin is available. */
16
16
  const STARTUP_VBS = "GrokTelegramBot.vbs";
17
17
 
18
+ function taskName(spec?: { windowsTaskName?: string }): string {
19
+ return spec?.windowsTaskName || TASK;
20
+ }
21
+
22
+ function startupVbsFile(spec?: { slug?: string }): string {
23
+ return spec?.slug ? `GrokTelegramBot-${spec.slug}.vbs` : STARTUP_VBS;
24
+ }
25
+
18
26
  /** The per-user Startup folder (runs at logon for the current user, no admin).
19
27
  * Undefined only if APPDATA is unset (e.g. running with no roaming profile). */
20
28
  function startupDir(): string | undefined {
@@ -22,9 +30,9 @@ function startupDir(): string | undefined {
22
30
  return appData ? join(appData, "Microsoft", "Windows", "Start Menu", "Programs", "Startup") : undefined;
23
31
  }
24
32
 
25
- function startupVbsPath(): string | undefined {
33
+ function startupVbsPath(spec?: { slug?: string }): string | undefined {
26
34
  const dir = startupDir();
27
- return dir ? join(dir, STARTUP_VBS) : undefined;
35
+ return dir ? join(dir, startupVbsFile(spec)) : undefined;
28
36
  }
29
37
 
30
38
  /** Remove a leftover Startup-folder launcher (e.g. from an earlier non-elevated
@@ -40,8 +48,8 @@ function vbsPath(spec: LaunchSpec): string {
40
48
  }
41
49
 
42
50
  /** True when our hidden Scheduled Task is registered. */
43
- function taskInstalled(): boolean {
44
- return runSafe("schtasks", ["/Query", "/TN", TASK]).ok;
51
+ function taskInstalled(spec?: { windowsTaskName?: string }): boolean {
52
+ return runSafe("schtasks", ["/Query", "/TN", taskName(spec)]).ok;
45
53
  }
46
54
 
47
55
  /** True when a bot process matching this spec is currently running. Launch
@@ -63,35 +71,36 @@ export const windowsController: ServiceController = {
63
71
  // Preferred: a hidden ONLOGON Scheduled Task. Registering a *logon-triggered*
64
72
  // task is a privileged operation, so /Create succeeds only from an elevated
65
73
  // (admin) terminal. From a normal terminal it returns "Access is denied".
66
- runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // replace if present
74
+ const tn = taskName(spec);
75
+ runSafe("schtasks", ["/Delete", "/F", "/TN", tn]); // replace if present
67
76
  const res = runSafe("schtasks", [
68
77
  "/Create",
69
78
  "/F",
70
79
  "/SC",
71
80
  "ONLOGON",
72
81
  "/TN",
73
- TASK,
82
+ tn,
74
83
  "/TR",
75
84
  `wscript.exe "${vbs}"`,
76
85
  ]);
77
86
  if (res.ok) {
78
87
  removeStartupLauncher(); // avoid a leftover launcher double-starting the bot
79
- if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
80
- return ok(`Installed scheduled task "${TASK}" (starts at logon) and launched it.`);
88
+ if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", tn]);
89
+ return ok(`Installed scheduled task "${tn}" (starts at logon) and launched it.`);
81
90
  }
82
91
 
83
92
  // A task may still exist that we just couldn't overwrite (e.g. created by an
84
93
  // earlier elevated install). Reuse it rather than ALSO adding a Startup
85
94
  // launcher, which would double-launch the bot at logon (409 Conflict).
86
- if (taskInstalled()) {
95
+ if (taskInstalled(spec)) {
87
96
  removeStartupLauncher();
88
- if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", TASK]);
89
- return ok(`Scheduled task "${TASK}" already exists; launched it. (Re-run elevated to recreate it.)`);
97
+ if (!isRunning(spec)) runSafe("schtasks", ["/Run", "/TN", tn]);
98
+ return ok(`Scheduled task "${tn}" already exists; launched it. (Re-run elevated to recreate it.)`);
90
99
  }
91
100
 
92
101
  // Fallback (no admin — the common case): drop the launcher in the per-user
93
102
  // Startup folder. It runs hidden at every logon with no elevation.
94
- const startupVbs = startupVbsPath();
103
+ const startupVbs = startupVbsPath(spec);
95
104
  const dir = startupDir();
96
105
  if (!startupVbs || !dir) {
97
106
  return fail(
@@ -118,21 +127,21 @@ export const windowsController: ServiceController = {
118
127
 
119
128
  async uninstall(spec) {
120
129
  await this.stop(spec);
121
- runSafe("schtasks", ["/Delete", "/F", "/TN", TASK]); // best-effort (may not exist)
130
+ runSafe("schtasks", ["/Delete", "/F", "/TN", taskName(spec)]); // best-effort (may not exist)
122
131
  rmSync(vbsPath(spec), { force: true });
123
- const startupVbs = startupVbsPath();
132
+ const startupVbs = startupVbsPath(spec);
124
133
  if (startupVbs) rmSync(startupVbs, { force: true });
125
- return ok(`Removed "${TASK}" (scheduled task and/or Startup launcher).`);
134
+ return ok(`Removed "${taskName(spec)}" (scheduled task and/or Startup launcher).`);
126
135
  },
127
136
 
128
137
  async start(spec) {
129
138
  if (isRunning(spec)) return ok("Already running.");
130
- if (taskInstalled()) {
139
+ if (taskInstalled(spec)) {
131
140
  // schtasks /Run returns once the task is queued (does not wait for the bot).
132
- const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
141
+ const res = runSafe("schtasks", ["/Run", "/TN", taskName(spec)]);
133
142
  return res.ok ? ok("Started.") : fail(res.out);
134
143
  }
135
- const startupVbs = startupVbsPath();
144
+ const startupVbs = startupVbsPath(spec);
136
145
  if (startupVbs && existsSync(startupVbs)) {
137
146
  // Forever-restart VBS — must be detached or this CLI never returns.
138
147
  const launched = launchDetached("wscript.exe", [startupVbs]);
@@ -148,20 +157,20 @@ export const windowsController: ServiceController = {
148
157
  },
149
158
 
150
159
  async stop(spec) {
151
- runSafe("schtasks", ["/End", "/TN", TASK]); // best-effort if task-based
160
+ runSafe("schtasks", ["/End", "/TN", taskName(spec)]); // best-effort if task-based
152
161
  const res = runSafe("powershell", ["-NoProfile", "-Command", killScript(entryOf(spec))]);
153
162
  return ok(`Stopped. ${res.out.trim()}`);
154
163
  },
155
164
 
156
165
  async status(spec) {
157
- const installedTask = taskInstalled();
158
- const startupVbs = startupVbsPath();
166
+ const installedTask = taskInstalled(spec);
167
+ const startupVbs = startupVbsPath(spec);
159
168
  const installedStartup = !!startupVbs && existsSync(startupVbs);
160
169
  const installed = installedTask || installedStartup;
161
170
  const running = isRunning(spec);
162
171
  const how = installedTask ? "scheduled task" : installedStartup ? "Startup folder" : "—";
163
172
  const detail = installedTask
164
- ? `\n${runSafe("schtasks", ["/Query", "/TN", TASK, "/FO", "LIST"]).out.trim()}`
173
+ ? `\n${runSafe("schtasks", ["/Query", "/TN", taskName(spec), "/FO", "LIST"]).out.trim()}`
165
174
  : installedStartup
166
175
  ? `\nLauncher: ${startupVbs}`
167
176
  : "";
@@ -10,8 +10,15 @@ import {
10
10
  TELEGRAM_BRIDGE_MARKER,
11
11
  TELEGRAM_BRIDGE_RESULTS_MARKER,
12
12
  } from "../render/telegram-bridge.js";
13
+ import {
14
+ MANAGER_DIRECTIVE_MARKER,
15
+ MANAGER_WORK_REPORT_MARKER,
16
+ } from "../render/manager-directive.js";
13
17
  import type { HistoryEntry, HistoryRole } from "./types.js";
14
18
 
19
+ /** Keep in sync with bot/manager-context.ts (avoid sessions→bot import). */
20
+ const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
21
+
15
22
  const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
16
23
 
17
24
  interface RawEvent {
@@ -261,6 +268,17 @@ function cleanStoredText(text: string): string {
261
268
  if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
262
269
  if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
263
270
  if (t.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER)) t = "";
271
+ if (t.trimStart().startsWith(MANAGER_WORK_REPORT_MARKER)) t = "";
272
+ // Manager directive / context: keep only the user message tail.
273
+ if (t.includes(MANAGER_CONTEXT_MARKER)) {
274
+ const split = t.split(/\n---\n\n/);
275
+ if (split.length > 1) t = split.slice(1).join("\n---\n\n").trim();
276
+ }
277
+ if (t.includes(MANAGER_DIRECTIVE_MARKER)) {
278
+ const um = "User message:";
279
+ const ui = t.lastIndexOf(um);
280
+ if (ui !== -1) t = t.slice(ui + um.length).trim();
281
+ }
264
282
  return t;
265
283
  }
266
284