metheus-governance-mcp-cli 0.2.102 → 0.2.103

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/README.md CHANGED
@@ -31,9 +31,9 @@ Install creates local provider settings templates here:
31
31
  These files are for local provider bot secrets, local transport options, and optional per-bot local AI binding.
32
32
 
33
33
  - Store locally:
34
- - Telegram-wide settings in `~/.metheus/telegram-bots/_global.env`
35
- - `TELEGRAM_BOT_TOKEN` as a legacy Telegram fallback in `_global.env`
36
- - one Telegram bot file per entry in `~/.metheus/telegram-bots/<server-bot-name>.env`
34
+ - Telegram-wide settings in `~/.metheus/telegram-bots/global.env`
35
+ - `TELEGRAM_BOT_TOKEN` as a legacy Telegram fallback in `global.env`
36
+ - one Telegram bot file per entry in `~/.metheus/telegram-bots/<ServerBotName>.env`
37
37
  - `SLACK_BOT_TOKEN`
38
38
  - `KAKAOTALK_BOT_TOKEN`
39
39
  - Server-side Metheus stores project chat destination metadata separately:
@@ -41,12 +41,13 @@ These files are for local provider bot secrets, local transport options, and opt
41
41
  - `chat_id` / channel / room identifier
42
42
  - label / active state
43
43
  - Do not put project chat destination identifiers in local env files.
44
- - Telegram-wide settings now live in `telegram-bots/_global.env`; Telegram per-bot secrets and AI fields live in `telegram-bots/*.env`.
44
+ - Telegram-wide settings now live in `telegram-bots/global.env`; Telegram per-bot secrets and server metadata live in `telegram-bots/*.env`.
45
+ - Grouped server bots also persist `TELEGRAM_BOT_SERVER_ROLE_IDS`, so the local file keeps the exact role-to-server-bot-id mapping instead of pretending that one grouped bot has only one server UUID.
45
46
 
46
47
  Example templates:
47
48
 
48
49
  ```env
49
- # ~/.metheus/telegram-bots/_global.env
50
+ # ~/.metheus/telegram-bots/global.env
50
51
  TELEGRAM_API_BASE_URL=
51
52
  TELEGRAM_AUTO_CLEAR_WEBHOOK=true
52
53
  TELEGRAM_ALLOWED_UPDATES=message,edited_message
@@ -57,10 +58,11 @@ TELEGRAM_BOT_TOKEN=
57
58
  ```
58
59
 
59
60
  ```env
60
- # ~/.metheus/telegram-bots/ryoai_bot.env
61
+ # ~/.metheus/telegram-bots/RyoAI_bot.env
61
62
  TELEGRAM_BOT_SERVER_BOT_ID=<server_bot_uuid>
62
63
  TELEGRAM_BOT_SERVER_NAME=RyoAI_bot
63
64
  TELEGRAM_BOT_SERVER_ROLES=monitor,review,worker,approval
65
+ TELEGRAM_BOT_SERVER_ROLE_IDS=monitor:<uuid>,review:<uuid>,worker:<uuid>,approval:<uuid>
64
66
  # Optional fallback only when server bot binding is unavailable
65
67
  # TELEGRAM_BOT_USERNAME=ryoai_bot
66
68
  TELEGRAM_BOT_TOKEN=
@@ -172,10 +174,12 @@ metheus-governance-mcp-cli setup --project-id <project_uuid> --ctxpack-key "<ctx
172
174
  - `~/.metheus/kakaotalk.env`
173
175
  - `~/.metheus/bot-runner.json`
174
176
 
177
+ Bootstrap or setup does not create one file per server bot automatically. Per-bot Telegram files are created later when you run `bot add`, `bot verify`, or `bot edit` against a real server bot identity.
178
+
175
179
  When Telegram local config already exists, bootstrap/setup keeps your secrets but auto-normalizes the layout to the latest split structure:
176
180
 
177
- - Telegram-wide settings stay in `~/.metheus/telegram-bots/_global.env`
178
- - per-bot secrets move to `~/.metheus/telegram-bots/<server-bot-name>.env`
181
+ - Telegram-wide settings stay in `~/.metheus/telegram-bots/global.env`
182
+ - per-bot secrets move to `~/.metheus/telegram-bots/<ServerBotName>.env`
179
183
  - stale inline keys such as `TELEGRAM_BOT_<NAME>_BOT_*` are rewritten into generic per-bot keys
180
184
 
181
185
  Fill provider bot secrets and provider-local transport options locally. Project chat destination identifiers should be managed on the Metheus server as project chat destinations, not as local env values and not inside legacy Chat Hooks/webhooks.
@@ -185,7 +189,7 @@ Fill provider bot secrets and provider-local transport options locally. Project
185
189
  - which provider/role bot profile to use
186
190
  - which `project_id -> workspace_dir` mapping to apply locally
187
191
  - which role profile maps to which local CLI/model/permission/reasoning policy
188
- - which server bot maps to which local LLM execution profile via `telegram-bots/_global.env`, `telegram-bots/*.env`, or fallback `bot_bindings`
192
+ - which server bot maps to which local LLM execution profile via `telegram-bots/global.env`, `telegram-bots/*.env`, or fallback `bot_bindings`
189
193
 
190
194
  Built-in helper command for legacy fallback/testing:
191
195
 
@@ -261,10 +265,11 @@ Behavior:
261
265
  - `bot set-default` without flags starts a guided numbered flow: provider -> bot entry -> confirm default change.
262
266
  - `bot verify` without flags starts a guided numbered flow: provider -> bot entry -> output format.
263
267
  - `bot remove` without flags starts a guided numbered flow: provider -> bot entry -> confirm removal.
264
- - Telegram stores one bot file per entry under `~/.metheus/telegram-bots/<server-bot-name>.env` with generic fields:
268
+ - Telegram stores one bot file per entry under `~/.metheus/telegram-bots/<ServerBotName>.env` with generic fields:
265
269
  - `TELEGRAM_BOT_SERVER_BOT_ID`
266
270
  - `TELEGRAM_BOT_SERVER_NAME`
267
271
  - `TELEGRAM_BOT_SERVER_ROLES`
272
+ - `TELEGRAM_BOT_SERVER_ROLE_IDS`
268
273
  - `TELEGRAM_BOT_USERNAME` only as a fallback when server bot binding is unavailable
269
274
  - `TELEGRAM_BOT_TOKEN`
270
275
  - `TELEGRAM_BOT_ROLE_PROFILE`
@@ -293,7 +298,7 @@ metheus-governance-mcp-cli bot remove --provider telegram --bot-name RyoAI_bot -
293
298
  metheus-governance-mcp-cli bot verify --provider telegram --bot-name RyoAI_bot --json true
294
299
  ```
295
300
 
296
- For direct Telegram adds, the CLI derives the local file name from the matched server bot name. You normally do not need `--bot-key` or `--username`. Treat `--bot-key` as an advanced compatibility selector only when you intentionally need a different local file stem.
301
+ For direct Telegram adds, the CLI derives the local file name from the matched server bot name. You normally do not need `--bot-key` or `--username`. Treat `--bot-key` as an advanced compatibility selector only when you intentionally need to target an older local selector directly.
297
302
 
298
303
  For direct Telegram edits, prefer `--bot-name` or `--bot-id` because server bot identity is the source of truth. Use `--bot-key` only when you intentionally need the advanced local selector. If one server bot name expands to multiple roles such as `approval / worker / review / monitor`, prefer the guided `bot edit` flow so you can keep the current grouped settings, edit one role only, or walk every role in sequence instead of forcing one entry-level AI override.
299
304
 
@@ -377,6 +382,7 @@ Execution model:
377
382
  Commands:
378
383
 
379
384
  ```bash
385
+ metheus-governance-mcp-cli runner list
380
386
  metheus-governance-mcp-cli runner once --route-name telegram-monitor
381
387
  metheus-governance-mcp-cli runner start --route-name telegram-monitor
382
388
  ```
@@ -403,7 +409,7 @@ Recommended production path:
403
409
  - keep `project_mappings.<project_id>.workspace_dir` aligned to that teammate's actual local project folder
404
410
  - let `ctxpack pull` or project connection refresh the mapping automatically
405
411
  - keep per-role execution policy under `role_profiles`
406
- - keep Telegram-wide binding defaults in `~/.metheus/telegram-bots/_global.env`
412
+ - keep Telegram-wide binding defaults in `~/.metheus/telegram-bots/global.env`
407
413
  - use `bot_bindings` in `bot-runner.json` only as local fallback/override
408
414
  - runner resolution order is: explicit `route.role_profile` -> provider env bot binding -> `bot_bindings` -> server bot role -> `route.role`
409
415
 
@@ -476,7 +482,7 @@ Notes:
476
482
  - `local-bot-bridge` reads stdin JSON from the runner and can call Codex/Claude/Gemini for you
477
483
  - `route.command` fallback is disabled by default; enable it only temporarily with `METHEUS_ALLOW_LEGACY_RUNNER_COMMAND=1`
478
484
  - today this automation path is implemented for Telegram end-to-end
479
- - prefer `TELEGRAM_API_BASE_URL=` inside `~/.metheus/telegram-bots/_global.env` for local Telegram API overrides; `METHEUS_TELEGRAM_API_BASE_URL` remains a process-level fallback mainly for mock/regression testing
485
+ - prefer `TELEGRAM_API_BASE_URL=` inside `~/.metheus/telegram-bots/global.env` for local Telegram API overrides; `METHEUS_TELEGRAM_API_BASE_URL` remains a process-level fallback mainly for mock/regression testing
480
486
  - Slack can use direct local send, but automatic inbound runner flow is not completed yet
481
487
  - KakaoTalk config can be stored now, but direct send/runner flow is not implemented yet
482
488
  - `doctor` now reports provider support for both enabled runner routes and active project chat destinations
package/cli.mjs CHANGED
@@ -154,8 +154,9 @@ const AUTH_STORE_RELATIVE_PATH = path.join(".metheus", "governance-mcp-auth.json
154
154
  const BOT_RUNNER_CONFIG_RELATIVE_PATH = path.join(".metheus", "bot-runner.json");
155
155
  const BOT_RUNNER_STATE_RELATIVE_PATH = path.join(".metheus", "bot-runner-state.json");
156
156
  const BOT_RUNNER_CONFIG_VERSION = 2;
157
- const TELEGRAM_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
158
- const TELEGRAM_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "_global.env");
157
+ const TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH = path.join(".metheus", "telegram.env");
158
+ const TELEGRAM_LEGACY_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "_global.env");
159
+ const TELEGRAM_GLOBAL_ENV_RELATIVE_PATH = path.join(".metheus", "telegram-bots", "global.env");
159
160
  const PROVIDER_ENV_CONFIG = {
160
161
  telegram: {
161
162
  relativePath: TELEGRAM_GLOBAL_ENV_RELATIVE_PATH,
@@ -253,6 +254,7 @@ function printUsage() {
253
254
  ` ${cmd} proxy [--project-id <uuid>] [--ctxpack-key <key>] [--base-url <url>] [--workspace-dir <path|auto>] [--include-drafts <true|false>] [--auto-pull-on-conflict <true|false>] [--timeout-seconds <n>]`,
254
255
  ` ${cmd} selftest [--json <true|false>]`,
255
256
  ` ${cmd} local-bot-bridge [--client <codex|claude|gemini|sample>] [--cwd <path>] [--model <name>] [--permission-mode <read_only|workspace_write|danger_full_access>] [--reasoning-effort <low|medium|high>]`,
257
+ ` ${cmd} runner list [--json <true|false>]`,
256
258
  ` ${cmd} runner once [--route-name <name>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--context-comments <n>] [--archive-replies <true|false>]`,
257
259
  ` ${cmd} runner start [--route-name <name>] [--project-id <uuid>] [--provider <telegram|slack|kakaotalk>] [--role <monitor|review|worker|approval>] [--role-profile <name>] [--bot-name <name>] [--bot-id <uuid>] [--mentions-only <true|false>] [--reply-to-bot-messages <true|false>] [--direct-messages <true|false>] [--ignore-edited-messages <true|false>] [--dry-run-delivery <true|false>] [--poll-interval-ms <n>] [--context-comments <n>] [--archive-replies <true|false>]`,
258
260
  ` ${cmd} ctxpack pull [--project-id <uuid>] [--base-url <url>] [--workspace-dir <path|auto>] [--paths <csv>] [--timeout-seconds <n>]`,
@@ -739,18 +741,57 @@ function providerEnvFilePath(provider) {
739
741
  }
740
742
 
741
743
  function telegramLegacyEnvFilePath() {
742
- return resolveHomeFilePath(TELEGRAM_LEGACY_ENV_RELATIVE_PATH);
744
+ return resolveHomeFilePath(TELEGRAM_ROOT_LEGACY_ENV_RELATIVE_PATH);
745
+ }
746
+
747
+ function telegramLegacyGlobalEnvFilePath() {
748
+ return resolveHomeFilePath(TELEGRAM_LEGACY_GLOBAL_ENV_RELATIVE_PATH);
743
749
  }
744
750
 
745
751
  function telegramBotEntriesDirPath() {
746
752
  return resolveHomeFilePath(".metheus/telegram-bots");
747
753
  }
748
754
 
755
+ function normalizeComparablePath(rawPath) {
756
+ const resolved = path.resolve(String(rawPath || ""));
757
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
758
+ }
759
+
760
+ function sanitizeTelegramBotFileStem(rawValue, fallback = "telegram_bot") {
761
+ const text = String(rawValue || "")
762
+ .trim()
763
+ .replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
764
+ .replace(/\s+/g, "_")
765
+ .replace(/^_+|_+$/g, "");
766
+ return text || fallback;
767
+ }
768
+
749
769
  function telegramBotEntryFilePath(botKey) {
750
770
  const normalizedKey = normalizeTelegramBotEnvKey(botKey || "", "telegram_bot") || "telegram_bot";
751
771
  return path.join(telegramBotEntriesDirPath(), `${normalizedKey}.env`);
752
772
  }
753
773
 
774
+ function telegramBotEntryFilePathForEntry(entryRaw) {
775
+ const entry = safeObject(entryRaw);
776
+ const fallbackKey = normalizeTelegramBotEnvKey(entry.key || "", "telegram_bot") || "telegram_bot";
777
+ const preferredStem = sanitizeTelegramBotFileStem(
778
+ firstNonEmptyString([entry.serverBotName, entry.username, entry.key]),
779
+ fallbackKey,
780
+ );
781
+ return path.join(telegramBotEntriesDirPath(), `${preferredStem}.env`);
782
+ }
783
+
784
+ function findTelegramEntryPathVariant(targetPath) {
785
+ const dirPath = path.dirname(targetPath);
786
+ if (!fs.existsSync(dirPath)) return "";
787
+ const targetComparable = normalizeComparablePath(targetPath);
788
+ const match = fs.readdirSync(dirPath, { withFileTypes: true })
789
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
790
+ .map((item) => path.join(dirPath, item.name))
791
+ .find((candidatePath) => normalizeComparablePath(candidatePath) === targetComparable);
792
+ return String(match || "").trim();
793
+ }
794
+
754
795
  function providerEnvTemplate(provider) {
755
796
  const config = providerEnvConfig(provider);
756
797
  if (normalizeBotProvider(provider) === "telegram") {
@@ -758,7 +799,7 @@ function providerEnvTemplate(provider) {
758
799
  "# Metheus local Telegram bot settings",
759
800
  "# Keep this file on your machine only. Do not commit it.",
760
801
  "# Store Telegram-wide settings here.",
761
- "# This global file now lives in ~/.metheus/telegram-bots/_global.env.",
802
+ "# This global file now lives in ~/.metheus/telegram-bots/global.env.",
762
803
  "# Per-bot secrets and AI settings live in ~/.metheus/telegram-bots/<server-bot-name>.env.",
763
804
  "# Project chat destinations must be managed on the Metheus server.",
764
805
  "",
@@ -2525,6 +2566,57 @@ async function runRunnerOnce(flags) {
2525
2566
  }
2526
2567
  }
2527
2568
 
2569
+ function buildRunnerRouteListRows() {
2570
+ const config = loadBotRunnerConfig({ persistIfNeeded: true });
2571
+ return ensureArray(config.routes).map((rawRoute, index) => {
2572
+ const route = normalizeRunnerRoute(rawRoute);
2573
+ return {
2574
+ index: index + 1,
2575
+ name: route.name || runnerRouteKey(route),
2576
+ enabled: route.enabled !== false,
2577
+ provider: route.provider || "-",
2578
+ projectID: route.projectID || "-",
2579
+ botName: route.botName || "-",
2580
+ botID: route.botID || "-",
2581
+ role: route.role || "-",
2582
+ roleProfile: route.roleProfile || "-",
2583
+ destinationLabel: route.destinationLabel || "-",
2584
+ pollIntervalMs: route.pollIntervalMs || 0,
2585
+ configFilePath: config.filePath,
2586
+ };
2587
+ });
2588
+ }
2589
+
2590
+ async function runRunnerList(flags) {
2591
+ const rows = buildRunnerRouteListRows();
2592
+ if (boolFromRaw(flags.json, false)) {
2593
+ process.stdout.write(`${JSON.stringify({ ok: true, routes: rows }, null, 2)}\n`);
2594
+ return;
2595
+ }
2596
+ process.stdout.write("Runner routes\n");
2597
+ if (!rows.length) {
2598
+ process.stdout.write(" none configured\n");
2599
+ return;
2600
+ }
2601
+ process.stdout.write(` file: ${rows[0].configFilePath}\n`);
2602
+ rows.forEach((row) => {
2603
+ process.stdout.write(
2604
+ [
2605
+ ` - ${row.name}${row.enabled ? "" : " [disabled]"}`,
2606
+ ` provider: ${row.provider}`,
2607
+ ` project_id: ${row.projectID}`,
2608
+ ` bot_name: ${row.botName}`,
2609
+ ` bot_id: ${row.botID}`,
2610
+ ` role: ${row.role}`,
2611
+ ` role_profile: ${row.roleProfile}`,
2612
+ ` destination_label: ${row.destinationLabel}`,
2613
+ ` poll_interval_ms: ${row.pollIntervalMs}`,
2614
+ ` run_once: ${CLI_NAME} runner once --route-name ${row.name}`,
2615
+ ].join("\n") + "\n",
2616
+ );
2617
+ });
2618
+ }
2619
+
2528
2620
  async function runRunnerStart(flags) {
2529
2621
  const jsonMode = boolFromRaw(flags.json, false);
2530
2622
  const routes = resolveRunnerRoutes(flags, "start");
@@ -2585,6 +2677,10 @@ async function runRunner(argv) {
2585
2677
  const [subcommandRaw = "", ...rest] = argv;
2586
2678
  const subcommand = String(subcommandRaw || "").trim().toLowerCase();
2587
2679
  const flags = parseArgs(rest);
2680
+ if (subcommand === "list") {
2681
+ await runRunnerList(flags);
2682
+ return;
2683
+ }
2588
2684
  if (subcommand === "once") {
2589
2685
  await runRunnerOnce(flags);
2590
2686
  return;
@@ -2593,7 +2689,7 @@ async function runRunner(argv) {
2593
2689
  await runRunnerStart(flags);
2594
2690
  return;
2595
2691
  }
2596
- throw new Error("runner requires a subcommand: once | start");
2692
+ throw new Error("runner requires a subcommand: list | once | start");
2597
2693
  }
2598
2694
 
2599
2695
  async function runLocalBotBridge(argv) {
@@ -2707,12 +2803,39 @@ function parseTelegramServerRoles(rawValue) {
2707
2803
  return Array.from(new Set(preferredTelegramServerRoleSort(values)));
2708
2804
  }
2709
2805
 
2806
+ function parseTelegramServerRoleIDs(rawValue) {
2807
+ const output = {};
2808
+ String(rawValue || "")
2809
+ .split(",")
2810
+ .map((value) => String(value || "").trim())
2811
+ .filter(Boolean)
2812
+ .forEach((pair) => {
2813
+ const separatorIndex = pair.indexOf(":");
2814
+ if (separatorIndex <= 0) return;
2815
+ const role = String(pair.slice(0, separatorIndex) || "").trim();
2816
+ const id = String(pair.slice(separatorIndex + 1) || "").trim();
2817
+ if (!role || !id) return;
2818
+ output[role] = id;
2819
+ });
2820
+ return preferredTelegramServerRoleSort(Object.keys(output)).reduce((acc, role) => {
2821
+ acc[role] = output[role];
2822
+ return acc;
2823
+ }, {});
2824
+ }
2825
+
2826
+ function formatTelegramServerRoleIDs(roleIDs) {
2827
+ return preferredTelegramServerRoleSort(Object.keys(safeObject(roleIDs)))
2828
+ .map((role) => `${role}:${String(safeObject(roleIDs)[role] || "").trim()}`)
2829
+ .filter((item) => !item.endsWith(":"))
2830
+ .join(",");
2831
+ }
2832
+
2710
2833
  function collectTelegramEnvBotEntries(parsedEnv) {
2711
2834
  const parsed = safeObject(parsedEnv);
2712
2835
  const entries = new Map();
2713
2836
  for (const [rawKey, rawValue] of Object.entries(parsed)) {
2714
2837
  const match = String(rawKey || "").trim().match(
2715
- /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i,
2838
+ /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|SERVER_ROLE_IDS|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i,
2716
2839
  );
2717
2840
  if (!match) continue;
2718
2841
  const botKey = normalizeTelegramBotEnvKey(match[1], "");
@@ -2725,6 +2848,7 @@ function collectTelegramEnvBotEntries(parsedEnv) {
2725
2848
  serverBotID: "",
2726
2849
  serverBotName: "",
2727
2850
  serverRoles: [],
2851
+ serverRoleIDs: {},
2728
2852
  roleProfile: "",
2729
2853
  client: "",
2730
2854
  model: "",
@@ -2742,6 +2866,8 @@ function collectTelegramEnvBotEntries(parsedEnv) {
2742
2866
  entry.serverBotName = textValue;
2743
2867
  } else if (field === "SERVER_ROLES") {
2744
2868
  entry.serverRoles = parseTelegramServerRoles(textValue);
2869
+ } else if (field === "SERVER_ROLE_IDS") {
2870
+ entry.serverRoleIDs = parseTelegramServerRoleIDs(textValue);
2745
2871
  } else if (field === "ROLE_PROFILE") {
2746
2872
  entry.roleProfile = normalizeRunnerRoleProfileName(textValue);
2747
2873
  } else if (field === "AI_CLIENT") {
@@ -2788,6 +2914,7 @@ function parseTelegramBotEntryFile(filePath) {
2788
2914
  serverBotID: String(parsed.TELEGRAM_BOT_SERVER_BOT_ID || "").trim(),
2789
2915
  serverBotName: String(parsed.TELEGRAM_BOT_SERVER_NAME || "").trim(),
2790
2916
  serverRoles: parseTelegramServerRoles(parsed.TELEGRAM_BOT_SERVER_ROLES || ""),
2917
+ serverRoleIDs: parseTelegramServerRoleIDs(parsed.TELEGRAM_BOT_SERVER_ROLE_IDS || ""),
2791
2918
  token: String(parsed.TELEGRAM_BOT_TOKEN || "").trim(),
2792
2919
  roleProfile: normalizeRunnerRoleProfileName(parsed.TELEGRAM_BOT_ROLE_PROFILE || ""),
2793
2920
  client: normalizeLocalAIClientName(parsed.TELEGRAM_BOT_AI_CLIENT || "", ""),
@@ -2824,7 +2951,11 @@ function loadTelegramBotEntriesFromFiles() {
2824
2951
  const dirPath = telegramBotEntriesDirPath();
2825
2952
  if (!fs.existsSync(dirPath)) return [];
2826
2953
  return fs.readdirSync(dirPath, { withFileTypes: true })
2827
- .filter((item) => item.isFile() && /\.env$/i.test(item.name) && item.name.toLowerCase() !== "_global.env")
2954
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
2955
+ .filter((item) => {
2956
+ const lowered = item.name.toLowerCase();
2957
+ return lowered !== "_global.env" && lowered !== "global.env";
2958
+ })
2828
2959
  .map((item) => path.join(dirPath, item.name))
2829
2960
  .sort((left, right) => left.localeCompare(right))
2830
2961
  .map((filePath) => {
@@ -2851,6 +2982,7 @@ function buildMergedTelegramEnvParsed(globalParsed, entries) {
2851
2982
  merged[`TELEGRAM_BOT_${upper}_SERVER_BOT_ID`] = String(entry.serverBotID || "").trim();
2852
2983
  merged[`TELEGRAM_BOT_${upper}_SERVER_NAME`] = String(entry.serverBotName || "").trim();
2853
2984
  merged[`TELEGRAM_BOT_${upper}_SERVER_ROLES`] = ensureArray(entry.serverRoles).join(",");
2985
+ merged[`TELEGRAM_BOT_${upper}_SERVER_ROLE_IDS`] = formatTelegramServerRoleIDs(entry.serverRoleIDs);
2854
2986
  merged[`TELEGRAM_BOT_${upper}_USERNAME`] = normalizeTelegramBotUsername(entry.username || "");
2855
2987
  merged[`TELEGRAM_BOT_${upper}_TOKEN`] = String(entry.token || "").trim();
2856
2988
  merged[`TELEGRAM_BOT_${upper}_ROLE_PROFILE`] = String(entry.roleProfile || "").trim();
@@ -2865,9 +2997,12 @@ function buildMergedTelegramEnvParsed(globalParsed, entries) {
2865
2997
  function readTelegramEnvState() {
2866
2998
  const filePath = providerEnvFilePath("telegram");
2867
2999
  const legacyFilePath = telegramLegacyEnvFilePath();
3000
+ const legacyGlobalFilePath = telegramLegacyGlobalEnvFilePath();
2868
3001
  let sourceFilePath = filePath;
2869
3002
  try {
2870
- if (!fs.existsSync(filePath) && fs.existsSync(legacyFilePath)) {
3003
+ if (!fs.existsSync(filePath) && fs.existsSync(legacyGlobalFilePath)) {
3004
+ sourceFilePath = legacyGlobalFilePath;
3005
+ } else if (!fs.existsSync(filePath) && fs.existsSync(legacyFilePath)) {
2871
3006
  sourceFilePath = legacyFilePath;
2872
3007
  } else if (!fs.existsSync(filePath)) {
2873
3008
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -2877,6 +3012,7 @@ function readTelegramEnvState() {
2877
3012
  return {
2878
3013
  filePath,
2879
3014
  legacyFilePath,
3015
+ legacyGlobalFilePath,
2880
3016
  sourceFilePath,
2881
3017
  entriesDirPath: telegramBotEntriesDirPath(),
2882
3018
  parsed: {},
@@ -2903,6 +3039,7 @@ function readTelegramEnvState() {
2903
3039
  return {
2904
3040
  filePath,
2905
3041
  legacyFilePath,
3042
+ legacyGlobalFilePath,
2906
3043
  sourceFilePath,
2907
3044
  entriesDirPath: telegramBotEntriesDirPath(),
2908
3045
  parsed: buildMergedTelegramEnvParsed(globalParsed, entries),
@@ -2912,7 +3049,7 @@ function readTelegramEnvState() {
2912
3049
  }
2913
3050
 
2914
3051
  function isTelegramEntryEnvKey(rawKey) {
2915
- return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
3052
+ return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|SERVER_ROLE_IDS|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
2916
3053
  .test(String(rawKey || "").trim());
2917
3054
  }
2918
3055
 
@@ -2935,7 +3072,7 @@ function renderNormalizedTelegramEnv(parsedEnv) {
2935
3072
  "# Metheus local Telegram bot settings",
2936
3073
  "# Keep this file on your machine only. Do not commit it.",
2937
3074
  "# Store Telegram-wide settings here.",
2938
- "# This global file lives in ~/.metheus/telegram-bots/_global.env.",
3075
+ "# This global file lives in ~/.metheus/telegram-bots/global.env.",
2939
3076
  "# Per-bot secrets and AI settings live in ~/.metheus/telegram-bots/<server-bot-name>.env.",
2940
3077
  "",
2941
3078
  `TELEGRAM_API_BASE_URL=${formatProviderEnvValue(parsed.TELEGRAM_API_BASE_URL || "")}`,
@@ -2971,6 +3108,7 @@ function renderTelegramBotEntryEnv(entryRaw) {
2971
3108
  const entry = safeObject(entryRaw);
2972
3109
  const botName = telegramEntryCommentNameForEnv(entry);
2973
3110
  const serverRoles = parseTelegramServerRoles(entry.serverRoles || "");
3111
+ const serverRoleIDs = formatTelegramServerRoleIDs(entry.serverRoleIDs);
2974
3112
  const lines = [
2975
3113
  "# Metheus local Telegram bot entry",
2976
3114
  `# Server bot: ${botName}`,
@@ -2978,6 +3116,7 @@ function renderTelegramBotEntryEnv(entryRaw) {
2978
3116
  `TELEGRAM_BOT_SERVER_BOT_ID=${formatProviderEnvValue(entry.serverBotID || "")}`,
2979
3117
  `TELEGRAM_BOT_SERVER_NAME=${formatProviderEnvValue(entry.serverBotName || "")}`,
2980
3118
  `TELEGRAM_BOT_SERVER_ROLES=${formatProviderEnvValue(serverRoles.join(","))}`,
3119
+ `TELEGRAM_BOT_SERVER_ROLE_IDS=${formatProviderEnvValue(serverRoleIDs)}`,
2981
3120
  ];
2982
3121
  if (!String(entry.serverBotID || "").trim() && !String(entry.serverBotName || "").trim() && String(entry.username || "").trim()) {
2983
3122
  lines.push(
@@ -3006,6 +3145,7 @@ function writeTelegramEnvState(parsedEnv) {
3006
3145
  || entry.serverBotID
3007
3146
  || entry.serverBotName
3008
3147
  || ensureArray(entry.serverRoles).length
3148
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
3009
3149
  || entry.roleProfile
3010
3150
  || entry.client
3011
3151
  || entry.model
@@ -3021,6 +3161,7 @@ function writeTelegramEnvState(parsedEnv) {
3021
3161
  });
3022
3162
  const globalFilePath = providerEnvFilePath("telegram");
3023
3163
  const legacyFilePath = telegramLegacyEnvFilePath();
3164
+ const legacyGlobalFilePath = telegramLegacyGlobalEnvFilePath();
3024
3165
  fs.mkdirSync(path.dirname(globalFilePath), { recursive: true });
3025
3166
  fs.writeFileSync(globalFilePath, renderNormalizedTelegramEnv(globalParsed), {
3026
3167
  encoding: "utf8",
@@ -3030,24 +3171,46 @@ function writeTelegramEnvState(parsedEnv) {
3030
3171
  fs.mkdirSync(entriesDirPath, { recursive: true });
3031
3172
  const activeFiles = new Set();
3032
3173
  entries.forEach((entry) => {
3033
- const entryFilePath = telegramBotEntryFilePath(entry.key);
3034
- activeFiles.add(path.resolve(entryFilePath));
3174
+ const entryFilePath = telegramBotEntryFilePathForEntry(entry);
3175
+ const previousEntryFilePath = String(entry.entryFilePath || "").trim();
3176
+ const discoveredEntryFilePath = previousEntryFilePath || findTelegramEntryPathVariant(entryFilePath);
3177
+ const sourceEntryFilePath = discoveredEntryFilePath || previousEntryFilePath;
3178
+ if (sourceEntryFilePath && sourceEntryFilePath !== entryFilePath && fs.existsSync(sourceEntryFilePath)) {
3179
+ const previousComparable = normalizeComparablePath(sourceEntryFilePath);
3180
+ const nextComparable = normalizeComparablePath(entryFilePath);
3181
+ if (previousComparable === nextComparable) {
3182
+ const tempRenamePath = path.join(
3183
+ path.dirname(entryFilePath),
3184
+ `.__rename__.${Date.now()}-${Math.random().toString(16).slice(2)}.env`,
3185
+ );
3186
+ fs.renameSync(sourceEntryFilePath, tempRenamePath);
3187
+ fs.renameSync(tempRenamePath, entryFilePath);
3188
+ }
3189
+ }
3190
+ activeFiles.add(normalizeComparablePath(entryFilePath));
3035
3191
  fs.writeFileSync(entryFilePath, renderTelegramBotEntryEnv(entry), {
3036
3192
  encoding: "utf8",
3037
3193
  mode: 0o600,
3038
3194
  });
3039
3195
  });
3040
3196
  fs.readdirSync(entriesDirPath, { withFileTypes: true })
3041
- .filter((item) => item.isFile() && /\.env$/i.test(item.name) && item.name.toLowerCase() !== "_global.env")
3197
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
3198
+ .filter((item) => {
3199
+ const lowered = item.name.toLowerCase();
3200
+ return lowered !== "_global.env" && lowered !== "global.env";
3201
+ })
3042
3202
  .forEach((item) => {
3043
- const filePath = path.resolve(path.join(entriesDirPath, item.name));
3203
+ const filePath = normalizeComparablePath(path.join(entriesDirPath, item.name));
3044
3204
  if (!activeFiles.has(filePath)) {
3045
- fs.rmSync(filePath, { force: true });
3205
+ fs.rmSync(path.join(entriesDirPath, item.name), { force: true });
3046
3206
  }
3047
3207
  });
3048
3208
  if (legacyFilePath !== globalFilePath && fs.existsSync(legacyFilePath)) {
3049
3209
  fs.rmSync(legacyFilePath, { force: true });
3050
3210
  }
3211
+ if (legacyGlobalFilePath !== globalFilePath && fs.existsSync(legacyGlobalFilePath)) {
3212
+ fs.rmSync(legacyGlobalFilePath, { force: true });
3213
+ }
3051
3214
  return globalFilePath;
3052
3215
  }
3053
3216
 
@@ -3067,6 +3230,7 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3067
3230
  || entry.serverBotID
3068
3231
  || entry.serverBotName
3069
3232
  || ensureArray(entry.serverRoles).length
3233
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
3070
3234
  ));
3071
3235
  const desiredBotID = firstNonEmptyString([
3072
3236
  selectors.serverBotID,
@@ -3118,7 +3282,8 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3118
3282
  provider: "telegram",
3119
3283
  providerLabel: config.label,
3120
3284
  filePath,
3121
- error: `TELEGRAM_BOT_${selected.key.toUpperCase()}_TOKEN is missing in ${filePath}`,
3285
+ entryFilePath: telegramBotEntryFilePathForEntry(selected),
3286
+ error: `TELEGRAM_BOT_TOKEN is missing in ${telegramBotEntryFilePathForEntry(selected) || filePath}`,
3122
3287
  token: "",
3123
3288
  };
3124
3289
  }
@@ -3127,14 +3292,16 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3127
3292
  provider: "telegram",
3128
3293
  providerLabel: config.label,
3129
3294
  filePath,
3295
+ entryFilePath: telegramBotEntryFilePathForEntry(selected),
3130
3296
  token: selected.token,
3131
3297
  source: "telegram_env_v2",
3132
- tokenKey: `TELEGRAM_BOT_${selected.key.toUpperCase()}_TOKEN`,
3298
+ tokenKey: "TELEGRAM_BOT_TOKEN",
3133
3299
  botKey: selected.key,
3134
3300
  botUsername: selected.username,
3135
3301
  serverBotID: selected.serverBotID,
3136
3302
  serverBotName: selected.serverBotName,
3137
3303
  serverRoles: ensureArray(selected.serverRoles),
3304
+ serverRoleIDs: safeObject(selected.serverRoleIDs),
3138
3305
  roleProfile: selected.roleProfile,
3139
3306
  client: selected.client,
3140
3307
  model: selected.model,
@@ -3161,6 +3328,7 @@ function resolveTelegramEnvConfig(parsedEnv, filePath, config, selectors = {}) {
3161
3328
  serverBotID: "",
3162
3329
  serverBotName: "",
3163
3330
  serverRoles: [],
3331
+ serverRoleIDs: {},
3164
3332
  roleProfile: "",
3165
3333
  client: "",
3166
3334
  model: "",
@@ -3732,6 +3900,7 @@ function buildBotCommandDeps() {
3732
3900
  providerEnvFilePath,
3733
3901
  telegramBotEntriesDirPath,
3734
3902
  telegramBotEntryFilePath,
3903
+ telegramBotEntryFilePathForEntry,
3735
3904
  ensureProviderEnvTemplate,
3736
3905
  parseCommandAndFlags,
3737
3906
  parseSimpleEnvText,
@@ -5521,7 +5690,7 @@ TELEGRAM_BOT_REVIEW_TOKEN=review-token
5521
5690
  const normalizedState = readTelegramEnvState();
5522
5691
  writeTelegramEnvState(normalizedState.parsed);
5523
5692
  normalizedGlobalText = fs.readFileSync(providerEnvFilePath("telegram"), "utf8");
5524
- normalizedBotText = fs.readFileSync(telegramBotEntryFilePath("ryoai_bot"), "utf8");
5693
+ normalizedBotText = fs.readFileSync(telegramBotEntryFilePathForEntry({ serverBotName: "RyoAI_bot", key: "ryoai_bot" }), "utf8");
5525
5694
  } finally {
5526
5695
  if (previousUserProfile === undefined) delete process.env.USERPROFILE;
5527
5696
  else process.env.USERPROFILE = previousUserProfile;
@@ -230,7 +230,7 @@ function printBotUsage(deps) {
230
230
  ` - bot add without flags uses the shortest practical guided flow: provider -> token -> verify -> optional username fallback -> optional default bot.`,
231
231
  ` - in the normal Telegram path, bot add does not ask for a local bot key or a server role/profile choice.`,
232
232
  ` - server bot name/UUID is the source of truth; --bot-key is an advanced local selector only.`,
233
- ` - runner commands use --route-name, not bot name. Use bot verify/show first, then run the linked route.`,
233
+ ` - runner commands use route names. Use runner list first, then run runner once/start with --route-name.`,
234
234
  ` - bot edit without flags asks for provider, existing entry, then walks field-by-field in a numbered flow.`,
235
235
  ` - in the normal Telegram edit path, the CLI keeps or re-resolves the server bot binding automatically instead of asking you to pick a server role/profile UUID first.`,
236
236
  ` - bot set-default / bot verify / bot remove also support guided numbered selection when run without flags.`,
@@ -445,6 +445,7 @@ function telegramEntryEnvKeys(botKey) {
445
445
  serverBotID: `TELEGRAM_BOT_${upper}_SERVER_BOT_ID`,
446
446
  serverBotName: `TELEGRAM_BOT_${upper}_SERVER_NAME`,
447
447
  serverRoles: `TELEGRAM_BOT_${upper}_SERVER_ROLES`,
448
+ serverRoleIDs: `TELEGRAM_BOT_${upper}_SERVER_ROLE_IDS`,
448
449
  username: `TELEGRAM_BOT_${upper}_USERNAME`,
449
450
  token: `TELEGRAM_BOT_${upper}_TOKEN`,
450
451
  roleProfile: `TELEGRAM_BOT_${upper}_ROLE_PROFILE`,
@@ -455,6 +456,52 @@ function telegramEntryEnvKeys(botKey) {
455
456
  };
456
457
  }
457
458
 
459
+ function parseTelegramServerRoleIDs(rawText) {
460
+ const output = {};
461
+ String(rawText || "")
462
+ .split(",")
463
+ .map((value) => String(value || "").trim())
464
+ .filter(Boolean)
465
+ .forEach((pair) => {
466
+ const separatorIndex = pair.indexOf(":");
467
+ if (separatorIndex <= 0) return;
468
+ const role = String(pair.slice(0, separatorIndex) || "").trim();
469
+ const id = String(pair.slice(separatorIndex + 1) || "").trim();
470
+ if (!role || !id) return;
471
+ output[role] = id;
472
+ });
473
+ return preferredRoleSort(Object.keys(output)).reduce((acc, role) => {
474
+ acc[role] = output[role];
475
+ return acc;
476
+ }, {});
477
+ }
478
+
479
+ function formatTelegramServerRoleIDs(roleIDs) {
480
+ return preferredRoleSort(Object.keys(safeObject(roleIDs)))
481
+ .map((role) => `${role}:${String(safeObject(roleIDs)[role] || "").trim()}`)
482
+ .filter((item) => !item.endsWith(":"))
483
+ .join(",");
484
+ }
485
+
486
+ function summarizeServerRoleIDs(bots) {
487
+ const output = {};
488
+ ensureArray(bots).forEach((bot) => {
489
+ const role = String(bot?.role || "").trim();
490
+ const id = String(bot?.id || "").trim();
491
+ if (!role || !id || output[role]) return;
492
+ output[role] = id;
493
+ });
494
+ return preferredRoleSort(Object.keys(output)).reduce((acc, role) => {
495
+ acc[role] = output[role];
496
+ return acc;
497
+ }, {});
498
+ }
499
+
500
+ function normalizeComparablePath(rawPath) {
501
+ const resolved = path.resolve(String(rawPath || ""));
502
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
503
+ }
504
+
458
505
  function telegramEntryCommentName(entry) {
459
506
  const current = safeObject(entry);
460
507
  const serverBotName = String(current.serverBotName || "").trim();
@@ -495,6 +542,7 @@ function upsertTelegramEntry(parsedEnv, entry) {
495
542
  next[keys.serverBotID] = String(entry.serverBotID || "").trim();
496
543
  next[keys.serverBotName] = String(entry.serverBotName || "").trim();
497
544
  next[keys.serverRoles] = ensureArray(entry.serverRoles).join(",");
545
+ next[keys.serverRoleIDs] = formatTelegramServerRoleIDs(entry.serverRoleIDs);
498
546
  next[keys.username] = persistTelegramUsername(entry);
499
547
  next[keys.token] = String(entry.token || "").trim();
500
548
  next[keys.roleProfile] = String(entry.roleProfile || "").trim();
@@ -521,7 +569,7 @@ function telegramKnownKeys(parsedEnv, deps) {
521
569
  }
522
570
 
523
571
  function isTelegramEntryEnvKey(rawKey) {
524
- return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
572
+ return /^TELEGRAM_BOT_([A-Z0-9_]+)_(TOKEN|USERNAME|SERVER_BOT_ID|SERVER_NAME|SERVER_ROLES|SERVER_ROLE_IDS|ROLE_PROFILE|AI_CLIENT|AI_MODEL|AI_PERMISSION_MODE|AI_REASONING_EFFORT)$/i
525
573
  .test(String(rawKey || "").trim());
526
574
  }
527
575
 
@@ -536,6 +584,7 @@ function renderTelegramEnv(parsedEnv, deps) {
536
584
  || entry.serverBotID
537
585
  || entry.serverBotName
538
586
  || ensureArray(entry.serverRoles).length
587
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
539
588
  || entry.roleProfile
540
589
  || entry.client
541
590
  || entry.model
@@ -575,6 +624,7 @@ function renderTelegramEnv(parsedEnv, deps) {
575
624
  `${keys.serverBotID}=${formatEnvValue(entry.serverBotID || "")}`,
576
625
  `${keys.serverBotName}=${formatEnvValue(entry.serverBotName || "")}`,
577
626
  `${keys.serverRoles}=${formatEnvValue(ensureArray(entry.serverRoles).join(","))}`,
627
+ `${keys.serverRoleIDs}=${formatEnvValue(formatTelegramServerRoleIDs(entry.serverRoleIDs))}`,
578
628
  ];
579
629
  if (String(entry.username || "").trim()) {
580
630
  entryLines.push(`${keys.username}=${formatEnvValue(entry.username || "")}`);
@@ -632,6 +682,7 @@ function renderTelegramBotEntryFile(entryRaw) {
632
682
  const entry = safeObject(entryRaw);
633
683
  const botName = telegramEntryCommentName(entry);
634
684
  const serverRoles = ensureArray(entry.serverRoles).join(",");
685
+ const serverRoleIDs = formatTelegramServerRoleIDs(entry.serverRoleIDs);
635
686
  const lines = [
636
687
  "# Metheus local Telegram bot entry",
637
688
  `# Server bot: ${botName}`,
@@ -639,6 +690,7 @@ function renderTelegramBotEntryFile(entryRaw) {
639
690
  `TELEGRAM_BOT_SERVER_BOT_ID=${formatEnvValue(entry.serverBotID || "")}`,
640
691
  `TELEGRAM_BOT_SERVER_NAME=${formatEnvValue(entry.serverBotName || "")}`,
641
692
  `TELEGRAM_BOT_SERVER_ROLES=${formatEnvValue(serverRoles)}`,
693
+ `TELEGRAM_BOT_SERVER_ROLE_IDS=${formatEnvValue(serverRoleIDs)}`,
642
694
  ];
643
695
  if (!String(entry.serverBotID || "").trim() && !String(entry.serverBotName || "").trim() && String(entry.username || "").trim()) {
644
696
  lines.push(
@@ -658,11 +710,47 @@ function renderTelegramBotEntryFile(entryRaw) {
658
710
  return `${lines.join("\n").trimEnd()}\n`;
659
711
  }
660
712
 
713
+ function appendTextSection(lines, title, sectionLines) {
714
+ const rows = ensureArray(sectionLines).filter(Boolean);
715
+ if (!rows.length) return;
716
+ lines.push(`${title}:`);
717
+ rows.forEach((row) => {
718
+ lines.push(` ${row}`);
719
+ });
720
+ }
721
+
722
+ function resolveTelegramEntryFilePath(entry, deps) {
723
+ const current = safeObject(entry);
724
+ if (!firstNonEmptyString([current.serverBotName, current.username, current.key])) {
725
+ return "";
726
+ }
727
+ if (typeof deps?.telegramBotEntryFilePathForEntry === "function") {
728
+ return String(deps.telegramBotEntryFilePathForEntry(entry) || "").trim();
729
+ }
730
+ return String(requireDependency(deps, "telegramBotEntryFilePath")(current.key) || "").trim();
731
+ }
732
+
661
733
  function rewriteTelegramEntryFile(entry, deps) {
662
- const entryFilePath = String(requireDependency(deps, "telegramBotEntryFilePath")(entry.key) || "").trim();
734
+ const entryFilePath = resolveTelegramEntryFilePath(entry, deps);
663
735
  if (!entryFilePath) return;
736
+ let rewritePath = entryFilePath;
737
+ if (fs.existsSync(path.dirname(entryFilePath))) {
738
+ const currentComparable = normalizeComparablePath(entryFilePath);
739
+ const existingVariant = fs.readdirSync(path.dirname(entryFilePath), { withFileTypes: true })
740
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name))
741
+ .map((item) => path.join(path.dirname(entryFilePath), item.name))
742
+ .find((candidatePath) => normalizeComparablePath(candidatePath) === currentComparable);
743
+ if (existingVariant && existingVariant !== entryFilePath) {
744
+ const tempRenamePath = path.join(
745
+ path.dirname(entryFilePath),
746
+ `.__rename__.${Date.now()}-${Math.random().toString(16).slice(2)}.env`,
747
+ );
748
+ fs.renameSync(existingVariant, tempRenamePath);
749
+ fs.renameSync(tempRenamePath, entryFilePath);
750
+ }
751
+ }
664
752
  fs.mkdirSync(path.dirname(entryFilePath), { recursive: true });
665
- fs.writeFileSync(entryFilePath, renderTelegramBotEntryFile(entry), {
753
+ fs.writeFileSync(rewritePath, renderTelegramBotEntryFile(entry), {
666
754
  encoding: "utf8",
667
755
  mode: 0o600,
668
756
  });
@@ -699,6 +787,7 @@ function telegramEntriesForDisplay(parsedEnv, deps) {
699
787
  || entry.serverBotID
700
788
  || entry.serverBotName
701
789
  || ensureArray(entry.serverRoles).length
790
+ || Object.keys(safeObject(entry.serverRoleIDs)).length
702
791
  || entry.roleProfile
703
792
  || entry.client
704
793
  || entry.model
@@ -861,11 +950,13 @@ async function editTelegramBotGuided(ui, parsed, selected, current, flags, deps)
861
950
  current.serverBotID = String(initialBinding.serverBotID || "").trim();
862
951
  current.serverBotName = String(initialBinding.name || "").trim();
863
952
  current.serverRoles = ensureArray(initialBinding.roles);
953
+ current.serverRoleIDs = safeObject(initialBinding.serverRoleIDs);
864
954
  current.roleProfile = String(initialBinding.role || current.roleProfile || "").trim();
865
955
  applyRoleProfileDefaults(current, resolveRoleProfileDefaults(current.roleProfile, deps));
866
956
  } else if (initialBinding.mode === "group") {
867
957
  current.serverBotName = String(initialBinding.name || "").trim();
868
958
  current.serverRoles = ensureArray(initialBinding.roles);
959
+ current.serverRoleIDs = safeObject(initialBinding.serverRoleIDs);
869
960
  }
870
961
  }
871
962
  }
@@ -918,6 +1009,7 @@ async function editTelegramBotGuided(ui, parsed, selected, current, flags, deps)
918
1009
  current.serverBotID = "";
919
1010
  current.serverBotName = String(serverBot.name || "").trim();
920
1011
  current.serverRoles = preferredRoleSort(serverBot.roles);
1012
+ current.serverRoleIDs = safeObject(serverBot.roleIDs);
921
1013
  current.__preferServerIdentity = true;
922
1014
  current.roleProfile = "";
923
1015
  current.client = "";
@@ -934,6 +1026,7 @@ async function editTelegramBotGuided(ui, parsed, selected, current, flags, deps)
934
1026
  current.serverBotID = String(serverBot.botID || "").trim();
935
1027
  current.serverBotName = String(serverBot.name || "").trim();
936
1028
  current.serverRoles = ensureArray(serverBot.roles);
1029
+ current.serverRoleIDs = safeObject(serverBot.roleIDs);
937
1030
  current.__preferServerIdentity = true;
938
1031
  current.roleProfile = String(serverBot.role || current.roleProfile || "").trim();
939
1032
  serverRoleAutoResolved = String(serverBot.role || current.roleProfile || "").trim() || "__server_binding__";
@@ -1062,14 +1155,17 @@ function renderBotListPayload(provider, state, deps) {
1062
1155
  return {
1063
1156
  provider,
1064
1157
  filePath: state.filePath,
1158
+ globalFilePath: state.filePath,
1065
1159
  support: requireDependency(deps, "summarizeProviderSupport")(provider),
1066
1160
  defaultBotKey: String(state.parsed.TELEGRAM_DEFAULT_BOT_KEY || "").trim(),
1067
1161
  entries: telegramEntriesForDisplay(state.parsed, deps).map((entry) => ({
1068
1162
  key: entry.key,
1069
1163
  isDefault: entry.isDefault,
1164
+ entryFilePath: resolveTelegramEntryFilePath(entry, deps),
1070
1165
  serverBotID: entry.serverBotID,
1071
1166
  serverBotName: entry.serverBotName,
1072
1167
  serverRoles: ensureArray(entry.serverRoles),
1168
+ serverRoleIDs: safeObject(entry.serverRoleIDs),
1073
1169
  username: entry.username,
1074
1170
  tokenConfigured: Boolean(entry.token),
1075
1171
  roleProfile: entry.roleProfile,
@@ -1106,14 +1202,18 @@ function buildBotShowPayload(provider, state, entry, deps, extras = {}) {
1106
1202
  return {
1107
1203
  provider,
1108
1204
  filePath: state.filePath,
1205
+ globalFilePath: state.filePath,
1206
+ entryFilePath: selectedEntry ? resolveTelegramEntryFilePath(selectedEntry, deps) : "",
1109
1207
  support: requireDependency(deps, "summarizeProviderSupport")(provider),
1110
1208
  defaultBotKey: String(state.parsed.TELEGRAM_DEFAULT_BOT_KEY || "").trim(),
1111
1209
  entry: selectedEntry ? {
1112
1210
  key: selectedEntry.key,
1113
1211
  isDefault: selectedEntry.isDefault,
1212
+ entryFilePath: resolveTelegramEntryFilePath(selectedEntry, deps),
1114
1213
  serverBotID: selectedEntry.serverBotID,
1115
1214
  serverBotName: selectedEntry.serverBotName,
1116
1215
  serverRoles: ensureArray(selectedEntry.serverRoles),
1216
+ serverRoleIDs: safeObject(selectedEntry.serverRoleIDs),
1117
1217
  username: selectedEntry.username,
1118
1218
  tokenConfigured: Boolean(selectedEntry.token),
1119
1219
  roleProfile: selectedEntry.roleProfile,
@@ -1210,8 +1310,10 @@ function printBotList(provider, state, deps) {
1210
1310
  process.stdout.write(
1211
1311
  [
1212
1312
  ` - ${telegramEntryDisplayName(entry)}${entry.isDefault ? " [default]" : ""}`,
1313
+ ` entry_file: ${resolveTelegramEntryFilePath(entry, deps) || "-"}`,
1213
1314
  ` local_selector: ${entry.key || "-"}${entry.key ? " (advanced)" : ""}`,
1214
1315
  ` server_bot_id: ${entry.serverBotID || "-"}`,
1316
+ ` server_role_ids: ${formatTelegramServerRoleIDs(entry.serverRoleIDs) || "-"}`,
1215
1317
  ` fallback_username: ${entry.username ? `@${entry.username}` : "-"}`,
1216
1318
  ` token: ${entry.token ? maskSecret(entry.token) : "-"}`,
1217
1319
  ` role_profile: ${entry.roleProfile || "-"}`,
@@ -1234,38 +1336,49 @@ function printBotShow(provider, state, entry, deps, extras = {}) {
1234
1336
  const selectedEntry = entry || {};
1235
1337
  const serverBinding = safeObject(extras.serverBinding);
1236
1338
  const routeLinks = safeObject(extras.routeLinks);
1237
- process.stdout.write(`${providerLabel(provider, deps)} bot\n`);
1238
- process.stdout.write(` file: ${state.filePath}\n`);
1239
- process.stdout.write(` server_name: ${telegramEntryDisplayName(selectedEntry)}\n`);
1240
- process.stdout.write(` local_selector: ${selectedEntry.key || "-"}${selectedEntry.key ? " (advanced)" : ""}\n`);
1241
- process.stdout.write(` default: ${selectedEntry.isDefault ? "yes" : "no"}\n`);
1242
- process.stdout.write(` stored_server_bot_id: ${selectedEntry.serverBotID || "-"}\n`);
1243
- process.stdout.write(` stored_server_name: ${selectedEntry.serverBotName || "-"}\n`);
1244
- process.stdout.write(` stored_server_roles: ${ensureArray(selectedEntry.serverRoles).join(", ") || "-" }\n`);
1245
- process.stdout.write(` fallback_username: ${selectedEntry.username ? `@${selectedEntry.username}` : "-"}\n`);
1246
- process.stdout.write(` token: ${selectedEntry.token ? maskSecret(selectedEntry.token) : "(not configured)"}\n`);
1247
- process.stdout.write(` role_profile: ${selectedEntry.roleProfile || "-"}\n`);
1248
- process.stdout.write(` ai_client: ${selectedEntry.client ? displayLocalAIClientName(selectedEntry.client) : "-"}\n`);
1249
- process.stdout.write(` ai_model: ${selectedEntry.model || "-"}\n`);
1250
- process.stdout.write(` permission_mode: ${selectedEntry.permissionMode || "-"}\n`);
1251
- process.stdout.write(` reasoning_effort: ${selectedEntry.reasoningEffort || "-"}\n`);
1339
+ const lines = [
1340
+ `${providerLabel(provider, deps)} bot`,
1341
+ `global_file: ${state.filePath}`,
1342
+ `entry_file: ${resolveTelegramEntryFilePath(selectedEntry, deps) || "-"}`,
1343
+ `server_name: ${telegramEntryDisplayName(selectedEntry)}`,
1344
+ ];
1345
+ appendTextSection(lines, "stored_local_entry", [
1346
+ `local_selector: ${selectedEntry.key || "-"}${selectedEntry.key ? " (advanced)" : ""}`,
1347
+ `default: ${selectedEntry.isDefault ? "yes" : "no"}`,
1348
+ `stored_server_bot_id: ${selectedEntry.serverBotID || "-"}`,
1349
+ `stored_server_name: ${selectedEntry.serverBotName || "-"}`,
1350
+ `stored_server_roles: ${ensureArray(selectedEntry.serverRoles).join(", ") || "-"}`,
1351
+ `stored_server_role_ids: ${formatTelegramServerRoleIDs(selectedEntry.serverRoleIDs) || "-"}`,
1352
+ `fallback_username: ${selectedEntry.username ? `@${selectedEntry.username}` : "-"}`,
1353
+ `token: ${selectedEntry.token ? maskSecret(selectedEntry.token) : "(not configured)"}`,
1354
+ `role_profile: ${selectedEntry.roleProfile || "-"}`,
1355
+ `ai_client: ${selectedEntry.client ? displayLocalAIClientName(selectedEntry.client) : "-"}`,
1356
+ `ai_model: ${selectedEntry.model || "-"}`,
1357
+ `permission_mode: ${selectedEntry.permissionMode || "-"}`,
1358
+ `reasoning_effort: ${selectedEntry.reasoningEffort || "-"}`,
1359
+ ]);
1252
1360
  if (Object.keys(serverBinding).length) {
1253
- process.stdout.write(` server_binding: ${serverBinding.ok ? "OK" : "FAIL"}${serverBinding.detail ? ` (${serverBinding.detail})` : ""}\n`);
1254
- process.stdout.write(` binding_mode: ${serverBinding.mode || "-"}\n`);
1255
- process.stdout.write(` live_server_name: ${serverBinding.name || "-"}\n`);
1256
- process.stdout.write(` live_server_bot_id: ${serverBinding.serverBotID || selectedEntry.serverBotID || "-" }\n`);
1361
+ const liveLines = [
1362
+ `status: ${serverBinding.ok ? "OK" : "FAIL"}${serverBinding.detail ? ` (${serverBinding.detail})` : ""}`,
1363
+ `binding_mode: ${serverBinding.mode || "-"}`,
1364
+ `live_server_name: ${serverBinding.name || "-"}`,
1365
+ `live_server_bot_id: ${serverBinding.serverBotID || selectedEntry.serverBotID || "-"}`,
1366
+ ];
1257
1367
  if (serverBinding.mode === "group") {
1258
- process.stdout.write(` live_server_roles: ${ensureArray(serverBinding.roles).join(", ") || "-"}\n`);
1368
+ liveLines.push(`live_server_roles: ${ensureArray(serverBinding.roles).join(", ") || "-"}`);
1369
+ liveLines.push(`live_server_role_ids: ${formatTelegramServerRoleIDs(serverBinding.serverRoleIDs) || "-"}`);
1259
1370
  const groupedProfiles = safeObject(serverBinding.effectiveRoleProfiles);
1260
1371
  Object.keys(groupedProfiles).forEach((role) => {
1261
- process.stdout.write(` runtime_role_profile[${role}]: ${formatRoleProfileOutputLine(groupedProfiles[role])}\n`);
1372
+ liveLines.push(`runtime_role_profile[${role}]: ${formatRoleProfileOutputLine(groupedProfiles[role])}`);
1262
1373
  });
1263
1374
  } else if (serverBinding.effectiveRoleProfile) {
1264
- process.stdout.write(` live_server_role: ${serverBinding.role || "-"}\n`);
1265
- process.stdout.write(` runtime_role_profile: ${formatRoleProfileOutputLine(serverBinding.effectiveRoleProfile)}\n`);
1375
+ liveLines.push(`live_server_role: ${serverBinding.role || "-"}`);
1376
+ liveLines.push(`live_server_role_ids: ${formatTelegramServerRoleIDs(serverBinding.serverRoleIDs) || "-"}`);
1377
+ liveLines.push(`runtime_role_profile: ${formatRoleProfileOutputLine(serverBinding.effectiveRoleProfile)}`);
1266
1378
  }
1379
+ appendTextSection(lines, "live_server_binding", liveLines);
1267
1380
  }
1268
- process.stdout.write(` route_links: ${intFromRaw(routeLinks.total, 0)}\n`);
1381
+ const routeLines = [`count: ${intFromRaw(routeLinks.total, 0)}`];
1269
1382
  ensureArray(routeLinks.routes).forEach((route) => {
1270
1383
  const routeRuntime = safeObject(route.runtimeRoleProfile);
1271
1384
  const routeDetail = [
@@ -1273,8 +1386,10 @@ function printBotShow(provider, state, entry, deps, extras = {}) {
1273
1386
  route.routeRole ? `route_role=${String(route.routeRole || "").trim()}` : "",
1274
1387
  Object.keys(routeRuntime).length ? `runtime=${formatRoleProfileOutputLine(routeRuntime)}` : "",
1275
1388
  ].filter(Boolean).join(" ");
1276
- process.stdout.write(` linked_route[${String(route.routeName || "-")}]: ${routeDetail || "-"}\n`);
1389
+ routeLines.push(`${String(route.routeName || "-")}: ${routeDetail || "-"}`);
1277
1390
  });
1391
+ appendTextSection(lines, "linked_runner_routes", routeLines);
1392
+ process.stdout.write(`${lines.join("\n")}\n`);
1278
1393
  return;
1279
1394
  }
1280
1395
  const tokenKey = providerTokenKey(provider, deps);
@@ -1343,6 +1458,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1343
1458
  role: "",
1344
1459
  name: "",
1345
1460
  roles: [],
1461
+ roleIDs: {},
1346
1462
  matchMode: manualID ? "manual" : "blank",
1347
1463
  };
1348
1464
  }
@@ -1360,6 +1476,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1360
1476
  role: String(match.role || "").trim(),
1361
1477
  name: String(match.name || "").trim(),
1362
1478
  roles: summarizeServerBotRoles([match]),
1479
+ roleIDs: summarizeServerRoleIDs([match]),
1363
1480
  matchMode: "exact",
1364
1481
  };
1365
1482
  }
@@ -1369,6 +1486,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1369
1486
  role: "",
1370
1487
  name: String(matchedByName[0]?.name || "").trim(),
1371
1488
  roles: summarizeServerBotRoles(matchedByName),
1489
+ roleIDs: summarizeServerRoleIDs(matchedByName),
1372
1490
  matchMode: "group",
1373
1491
  };
1374
1492
  }
@@ -1380,6 +1498,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1380
1498
  role: String(match.role || "").trim(),
1381
1499
  name: String(match.name || "").trim(),
1382
1500
  roles: summarizeServerBotRoles([match]),
1501
+ roleIDs: summarizeServerRoleIDs([match]),
1383
1502
  matchMode: "exact",
1384
1503
  };
1385
1504
  }
@@ -1399,6 +1518,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1399
1518
  role: "",
1400
1519
  name: String(groupedBots[0]?.name || "").trim(),
1401
1520
  roles: summarizeServerBotRoles(groupedBots),
1521
+ roleIDs: summarizeServerRoleIDs(groupedBots),
1402
1522
  matchMode: "group",
1403
1523
  };
1404
1524
  }
@@ -1453,7 +1573,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1453
1573
  { defaultIndex: 0 },
1454
1574
  );
1455
1575
  if (!roleChoice || roleChoice.value === "__blank__") {
1456
- return { botID: "", role: "", name: "", roles: [], matchMode: "blank" };
1576
+ return { botID: "", role: "", name: "", roles: [], roleIDs: {}, matchMode: "blank" };
1457
1577
  }
1458
1578
  if (roleChoice.value === "__manual__") {
1459
1579
  const manualID = await promptLine(ui, "Server bot UUID", "");
@@ -1462,6 +1582,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1462
1582
  role: "",
1463
1583
  name: "",
1464
1584
  roles: [],
1585
+ roleIDs: {},
1465
1586
  matchMode: manualID ? "manual" : "blank",
1466
1587
  };
1467
1588
  }
@@ -1473,6 +1594,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1473
1594
  role: String(match.role || "").trim(),
1474
1595
  name: String(match.name || "").trim(),
1475
1596
  roles: summarizeServerBotRoles([match]),
1597
+ roleIDs: summarizeServerRoleIDs([match]),
1476
1598
  matchMode: "exact",
1477
1599
  };
1478
1600
  }
@@ -1501,7 +1623,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1501
1623
  { defaultIndex: 0 },
1502
1624
  );
1503
1625
  if (!choice || choice.value === "__blank__") {
1504
- return { botID: "", role: "", name: "", roles: [], matchMode: "blank" };
1626
+ return { botID: "", role: "", name: "", roles: [], roleIDs: {}, matchMode: "blank" };
1505
1627
  }
1506
1628
  if (choice.value === "__manual__") {
1507
1629
  const manualID = await promptLine(ui, "Server bot UUID", "");
@@ -1510,6 +1632,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1510
1632
  role: selectedRole,
1511
1633
  name: "",
1512
1634
  roles: selectedRole ? [selectedRole] : [],
1635
+ roleIDs: {},
1513
1636
  matchMode: manualID ? "manual" : "blank",
1514
1637
  };
1515
1638
  }
@@ -1519,6 +1642,7 @@ async function chooseServerBot(ui, provider, baseURL, timeoutSeconds, deps, opti
1519
1642
  role: String(match.role || "").trim(),
1520
1643
  name: String(match.name || "").trim(),
1521
1644
  roles: summarizeServerBotRoles([match]),
1645
+ roleIDs: summarizeServerRoleIDs([match]),
1522
1646
  matchMode: "exact",
1523
1647
  };
1524
1648
  }
@@ -1534,7 +1658,7 @@ async function resolveServerBotForNonInteractive(provider, flags, deps, options
1534
1658
  ]),
1535
1659
  );
1536
1660
  if (!requestedBotID && !requestedName) {
1537
- return { botID: "", role: "", name: "", roles: [], matchMode: "blank" };
1661
+ return { botID: "", role: "", name: "", roles: [], roleIDs: {}, matchMode: "blank" };
1538
1662
  }
1539
1663
  const lookup = await requireDependency(deps, "listServerBots")({
1540
1664
  provider,
@@ -1563,6 +1687,7 @@ async function resolveServerBotForNonInteractive(provider, flags, deps, options
1563
1687
  role: "",
1564
1688
  name: match.name,
1565
1689
  roles: summarizeServerBotRoles(sameNameMatches),
1690
+ roleIDs: summarizeServerRoleIDs(sameNameMatches),
1566
1691
  matchMode: "group",
1567
1692
  };
1568
1693
  }
@@ -1571,6 +1696,7 @@ async function resolveServerBotForNonInteractive(provider, flags, deps, options
1571
1696
  role: match.role,
1572
1697
  name: match.name,
1573
1698
  roles: summarizeServerBotRoles([match]),
1699
+ roleIDs: summarizeServerRoleIDs([match]),
1574
1700
  matchMode: "exact",
1575
1701
  };
1576
1702
  }
@@ -1587,6 +1713,7 @@ async function resolveServerBotForNonInteractive(provider, flags, deps, options
1587
1713
  role: String(match.role || "").trim(),
1588
1714
  name: String(match.name || "").trim(),
1589
1715
  roles: summarizeServerBotRoles([match]),
1716
+ roleIDs: summarizeServerRoleIDs([match]),
1590
1717
  matchMode: "exact",
1591
1718
  };
1592
1719
  }
@@ -1595,6 +1722,7 @@ async function resolveServerBotForNonInteractive(provider, flags, deps, options
1595
1722
  role: "",
1596
1723
  name: String(matchedByName[0]?.name || "").trim(),
1597
1724
  roles: summarizeServerBotRoles(matchedByName),
1725
+ roleIDs: summarizeServerRoleIDs(matchedByName),
1598
1726
  matchMode: "group",
1599
1727
  };
1600
1728
  }
@@ -1966,6 +2094,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
1966
2094
  }
1967
2095
  const resolveGroupedPayload = (matches, matchedBy) => {
1968
2096
  const roles = preferredRoleSort(summarizeServerBotRoles(matches));
2097
+ const serverRoleIDs = summarizeServerRoleIDs(matches);
1969
2098
  return {
1970
2099
  ok: true,
1971
2100
  mode: "group",
@@ -1973,6 +2102,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
1973
2102
  name: String(matches[0]?.name || "").trim(),
1974
2103
  role: "",
1975
2104
  roles,
2105
+ serverRoleIDs,
1976
2106
  serverBotID: String(current.serverBotID || "").trim(),
1977
2107
  effectiveRoleProfile: null,
1978
2108
  effectiveRoleProfiles: buildGroupedRoleProfileOutput(roles, deps),
@@ -1989,6 +2119,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
1989
2119
  name: "",
1990
2120
  role: "",
1991
2121
  roles: [],
2122
+ serverRoleIDs: {},
1992
2123
  serverBotID: String(current.serverBotID || "").trim(),
1993
2124
  effectiveRoleProfile: null,
1994
2125
  effectiveRoleProfiles: {},
@@ -2007,6 +2138,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
2007
2138
  name: match.name,
2008
2139
  role: match.role,
2009
2140
  roles: summarizeServerBotRoles([match]),
2141
+ serverRoleIDs: summarizeServerRoleIDs([match]),
2010
2142
  serverBotID: match.id,
2011
2143
  effectiveRoleProfile,
2012
2144
  effectiveRoleProfiles: {},
@@ -2023,6 +2155,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
2023
2155
  name: "",
2024
2156
  role: "",
2025
2157
  roles: [],
2158
+ serverRoleIDs: {},
2026
2159
  serverBotID: "",
2027
2160
  effectiveRoleProfile: null,
2028
2161
  effectiveRoleProfiles: {},
@@ -2041,6 +2174,7 @@ function resolveTelegramServerBindingDetailsFromBots(envConfig, bots, deps) {
2041
2174
  name: String(match.name || "").trim(),
2042
2175
  role: String(match.role || "").trim(),
2043
2176
  roles: summarizeServerBotRoles([match]),
2177
+ serverRoleIDs: summarizeServerRoleIDs([match]),
2044
2178
  serverBotID: String(match.id || "").trim(),
2045
2179
  effectiveRoleProfile,
2046
2180
  effectiveRoleProfiles: {},
@@ -2062,6 +2196,7 @@ async function resolveTelegramServerBindingDetails(envConfig, flags, deps) {
2062
2196
  name: "",
2063
2197
  role: "",
2064
2198
  roles: [],
2199
+ serverRoleIDs: {},
2065
2200
  effectiveRoleProfile: null,
2066
2201
  effectiveRoleProfiles: {},
2067
2202
  detail: `server bot lookup unavailable: ${lookup?.error || "unknown error"}`,
@@ -2157,6 +2292,7 @@ async function autoResolveTelegramServerBot(current, flags, deps) {
2157
2292
  role: String(current?.roleProfile || "").trim(),
2158
2293
  name: "",
2159
2294
  roles: [],
2295
+ roleIDs: {},
2160
2296
  matchMode: existingBotID ? "existing" : "blank",
2161
2297
  };
2162
2298
  }
@@ -2180,6 +2316,7 @@ async function autoResolveTelegramServerBot(current, flags, deps) {
2180
2316
  role: String(current?.roleProfile || "").trim(),
2181
2317
  name: "",
2182
2318
  roles: [],
2319
+ roleIDs: {},
2183
2320
  matchMode: existingBotID ? "existing" : "blank",
2184
2321
  };
2185
2322
  }
@@ -2363,6 +2500,7 @@ async function addTelegramBot(ui, flags, deps) {
2363
2500
  serverBotID: String(getServerBotIDFlag(flags) || serverBot.botID || "").trim(),
2364
2501
  serverBotName: String(serverBot.name || "").trim(),
2365
2502
  serverRoles: preferredRoleSort(serverBot.roles),
2503
+ serverRoleIDs: safeObject(serverBot.roleIDs),
2366
2504
  username,
2367
2505
  __preferServerIdentity: serverBot.matchMode === "group" || Boolean(String(getServerBotIDFlag(flags) || serverBot.botID || "").trim()),
2368
2506
  token,
@@ -2485,6 +2623,7 @@ async function editTelegramBot(ui, flags, deps) {
2485
2623
  current.serverBotID = String(getServerBotIDFlag(flags) || current.serverBotID || "").trim();
2486
2624
  current.serverBotName = String(resolvedServerBot.name || current.serverBotName || "").trim();
2487
2625
  current.serverRoles = preferredRoleSort(resolvedServerBot.roles);
2626
+ current.serverRoleIDs = safeObject(resolvedServerBot.roleIDs);
2488
2627
  if (!hasOwnFlag(flags, "role-profile")) current.roleProfile = "";
2489
2628
  if (!(hasOwnFlag(flags, "client") || hasOwnFlag(flags, "ai-client"))) current.client = "";
2490
2629
  if (!(hasOwnFlag(flags, "model") || hasOwnFlag(flags, "ai-model"))) current.model = "";
@@ -2494,6 +2633,7 @@ async function editTelegramBot(ui, flags, deps) {
2494
2633
  current.serverBotID = String(resolvedServerBot.botID || "").trim();
2495
2634
  current.serverBotName = String(resolvedServerBot.name || "").trim();
2496
2635
  current.serverRoles = ensureArray(resolvedServerBot.roles);
2636
+ current.serverRoleIDs = safeObject(resolvedServerBot.roleIDs);
2497
2637
  }
2498
2638
  }
2499
2639
  saveTelegramBotEdit(parsed, selected, current, deps);
@@ -2576,9 +2716,12 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2576
2716
  if (selectedEntry) {
2577
2717
  const desiredServerBotName = String(serverBinding.name || "").trim();
2578
2718
  const desiredServerRoles = ensureArray(serverBinding.roles);
2719
+ const desiredServerRoleIDs = safeObject(serverBinding.serverRoleIDs);
2579
2720
  const currentServerRoles = ensureArray(selectedEntry.serverRoles);
2580
2721
  const currentServerRolesJoined = currentServerRoles.join(",");
2581
2722
  const desiredServerRolesJoined = desiredServerRoles.join(",");
2723
+ const currentServerRoleIDs = formatTelegramServerRoleIDs(selectedEntry.serverRoleIDs);
2724
+ const desiredServerRoleIDsText = formatTelegramServerRoleIDs(desiredServerRoleIDs);
2582
2725
  const desiredServerBotID = (
2583
2726
  String(selectedEntry.serverBotID || "").trim()
2584
2727
  || (serverBinding.mode === "single" ? String(serverBinding.serverBotID || "").trim() : "")
@@ -2586,6 +2729,7 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2586
2729
  const needsMetadataBackfill = (
2587
2730
  String(selectedEntry.serverBotName || "").trim() !== desiredServerBotName
2588
2731
  || currentServerRolesJoined !== desiredServerRolesJoined
2732
+ || currentServerRoleIDs !== desiredServerRoleIDsText
2589
2733
  || String(selectedEntry.serverBotID || "").trim() !== desiredServerBotID
2590
2734
  );
2591
2735
  if (needsMetadataBackfill) {
@@ -2594,6 +2738,7 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2594
2738
  serverBotID: desiredServerBotID,
2595
2739
  serverBotName: desiredServerBotName,
2596
2740
  serverRoles: desiredServerRoles,
2741
+ serverRoleIDs: desiredServerRoleIDs,
2597
2742
  };
2598
2743
  const nextParsed = upsertTelegramEntry(state.parsed, nextEntry);
2599
2744
  writeProviderEnvState("telegram", nextParsed, deps);
@@ -2601,6 +2746,7 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2601
2746
  envConfig.serverBotID = desiredServerBotID;
2602
2747
  envConfig.serverBotName = desiredServerBotName;
2603
2748
  envConfig.serverRoles = desiredServerRoles;
2749
+ envConfig.serverRoleIDs = desiredServerRoleIDs;
2604
2750
  }
2605
2751
  const rewriteEntry = needsMetadataBackfill
2606
2752
  ? {
@@ -2608,6 +2754,7 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2608
2754
  serverBotID: desiredServerBotID,
2609
2755
  serverBotName: desiredServerBotName,
2610
2756
  serverRoles: desiredServerRoles,
2757
+ serverRoleIDs: desiredServerRoleIDs,
2611
2758
  }
2612
2759
  : selectedEntry;
2613
2760
  const currentEntryFilePath = String(
@@ -2651,10 +2798,13 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2651
2798
  ok: overallOK,
2652
2799
  provider,
2653
2800
  filePath: envConfig.filePath,
2801
+ globalFilePath: envConfig.filePath,
2802
+ entryFilePath: provider === "telegram" ? resolveTelegramEntryFilePath(envConfig, deps) : "",
2654
2803
  botKey: envConfig.botKey || "",
2655
2804
  serverBotID: envConfig.serverBotID || "",
2656
2805
  serverBotName: envConfig.serverBotName || "",
2657
2806
  serverRoles: ensureArray(envConfig.serverRoles),
2807
+ serverRoleIDs: safeObject(envConfig.serverRoleIDs),
2658
2808
  detail: result.detail || "",
2659
2809
  roleProfile: envConfig.roleProfile || "",
2660
2810
  client: envConfig.client || "",
@@ -2668,34 +2818,44 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2668
2818
  } else {
2669
2819
  const lines = [
2670
2820
  `${providerLabel(provider, deps)} verify: ${overallOK ? "OK" : "FAIL"}`,
2671
- `file: ${envConfig.filePath}`,
2672
- provider === "telegram" ? `local_selector: ${envConfig.botKey || "-"}${envConfig.botKey ? " (advanced)" : ""}` : "",
2673
- provider === "telegram" ? `stored_server_bot_id: ${envConfig.serverBotID || "-"}` : "",
2674
- provider === "telegram" ? `stored_server_name: ${envConfig.serverBotName || "-"}` : "",
2675
- provider === "telegram" ? `stored_server_roles: ${ensureArray(envConfig.serverRoles).join(", ") || "-"}` : "",
2676
- provider === "telegram" ? `role_profile: ${envConfig.roleProfile || "-"}` : "",
2677
- provider === "telegram" ? `ai_client: ${envConfig.client ? displayLocalAIClientName(envConfig.client) : "-"}` : "",
2678
- provider === "telegram" ? `ai_model: ${envConfig.model || "-"}` : "",
2679
- provider === "telegram" ? `permission_mode: ${envConfig.permissionMode || "-"}` : "",
2680
- provider === "telegram" ? `reasoning_effort: ${envConfig.reasoningEffort || "-"}` : "",
2821
+ `global_file: ${envConfig.filePath}`,
2822
+ provider === "telegram" ? `entry_file: ${resolveTelegramEntryFilePath(envConfig, deps) || "-"}` : "",
2681
2823
  `detail: ${result.detail || "-"}`,
2682
- serverBinding ? `server_binding: ${serverBinding.ok ? "OK" : "FAIL"}${serverBinding.detail ? ` (${serverBinding.detail})` : ""}` : "",
2683
- routeLinks ? `route_links: ${intFromRaw(routeLinks.total, 0)}` : "",
2684
- ].filter(Boolean);
2824
+ ];
2825
+ if (provider === "telegram") {
2826
+ appendTextSection(lines, "stored_local_entry", [
2827
+ `local_selector: ${envConfig.botKey || "-"}${envConfig.botKey ? " (advanced)" : ""}`,
2828
+ `stored_server_bot_id: ${envConfig.serverBotID || "-"}`,
2829
+ `stored_server_name: ${envConfig.serverBotName || "-"}`,
2830
+ `stored_server_roles: ${ensureArray(envConfig.serverRoles).join(", ") || "-"}`,
2831
+ `stored_server_role_ids: ${formatTelegramServerRoleIDs(envConfig.serverRoleIDs) || "-"}`,
2832
+ `role_profile: ${envConfig.roleProfile || "-"}`,
2833
+ `ai_client: ${envConfig.client ? displayLocalAIClientName(envConfig.client) : "-"}`,
2834
+ `ai_model: ${envConfig.model || "-"}`,
2835
+ `permission_mode: ${envConfig.permissionMode || "-"}`,
2836
+ `reasoning_effort: ${envConfig.reasoningEffort || "-"}`,
2837
+ ]);
2838
+ }
2685
2839
  if (provider === "telegram" && serverBinding) {
2686
- lines.push(`binding_mode: ${serverBinding.mode || "-"}`);
2687
- lines.push(`live_server_name: ${serverBinding.name || "-"}`);
2688
- lines.push(`live_server_roles: ${ensureArray(serverBinding.roles).join(", ") || "-"}`);
2840
+ const liveLines = [
2841
+ `status: ${serverBinding.ok ? "OK" : "FAIL"}${serverBinding.detail ? ` (${serverBinding.detail})` : ""}`,
2842
+ `binding_mode: ${serverBinding.mode || "-"}`,
2843
+ `live_server_name: ${serverBinding.name || "-"}`,
2844
+ `live_server_roles: ${ensureArray(serverBinding.roles).join(", ") || "-"}`,
2845
+ `live_server_role_ids: ${formatTelegramServerRoleIDs(serverBinding.serverRoleIDs) || "-"}`,
2846
+ ];
2689
2847
  if (serverBinding.mode === "group") {
2690
2848
  const groupedProfiles = safeObject(serverBinding.effectiveRoleProfiles);
2691
2849
  Object.keys(groupedProfiles).forEach((role) => {
2692
- lines.push(`runtime_role_profile[${role}]: ${formatRoleProfileOutputLine(groupedProfiles[role])}`);
2850
+ liveLines.push(`runtime_role_profile[${role}]: ${formatRoleProfileOutputLine(groupedProfiles[role])}`);
2693
2851
  });
2694
2852
  } else if (serverBinding.effectiveRoleProfile) {
2695
- lines.push(`runtime_role_profile: ${formatRoleProfileOutputLine(serverBinding.effectiveRoleProfile)}`);
2853
+ liveLines.push(`runtime_role_profile: ${formatRoleProfileOutputLine(serverBinding.effectiveRoleProfile)}`);
2696
2854
  }
2855
+ appendTextSection(lines, "live_server_binding", liveLines);
2697
2856
  }
2698
2857
  if (provider === "telegram" && routeLinks) {
2858
+ const routeSection = [`count: ${intFromRaw(routeLinks.total, 0)}`];
2699
2859
  ensureArray(routeLinks.routes).forEach((route) => {
2700
2860
  const routeRuntime = safeObject(route.runtimeRoleProfile);
2701
2861
  const routeDetail = [
@@ -2703,8 +2863,9 @@ async function verifyProviderEntry(ui, provider, flags, deps) {
2703
2863
  route.routeRole ? `route_role=${String(route.routeRole || "").trim()}` : "",
2704
2864
  Object.keys(routeRuntime).length ? `runtime=${formatRoleProfileOutputLine(routeRuntime)}` : "",
2705
2865
  ].filter(Boolean).join(" ");
2706
- lines.push(`linked_route[${String(route.routeName || "-")}]: ${routeDetail || "-"}`);
2866
+ routeSection.push(`${String(route.routeName || "-")}: ${routeDetail || "-"}`);
2707
2867
  });
2868
+ appendTextSection(lines, "linked_runner_routes", routeSection);
2708
2869
  }
2709
2870
  process.stdout.write(`${lines.join("\n")}\n`);
2710
2871
  }
@@ -289,11 +289,22 @@ export async function runSelftestBotCommands(push, deps) {
289
289
  const baseURL = `http://127.0.0.1:${mock.port}`;
290
290
  const telegramApiBaseURL = `${baseURL}/telegram`;
291
291
  const telegramBotEntriesDir = path.join(tempHome, ".metheus", "telegram-bots");
292
- const telegramEnvPath = path.join(telegramBotEntriesDir, "_global.env");
292
+ const telegramEnvPath = path.join(telegramBotEntriesDir, "global.env");
293
293
  const readTelegramGlobals = () => parseSimpleEnvText(fs.readFileSync(telegramEnvPath, "utf8"));
294
- const readTelegramBotEntry = (botKey) => {
295
- const filePath = path.join(telegramBotEntriesDir, `${String(botKey || "").trim()}.env`);
296
- if (!fs.existsSync(filePath)) {
294
+ const readTelegramBotEntry = (selector) => {
295
+ const requested = String(selector || "").trim().toLowerCase();
296
+ const fileCandidates = fs.readdirSync(telegramBotEntriesDir, { withFileTypes: true })
297
+ .filter((item) => item.isFile() && /\.env$/i.test(item.name) && item.name.toLowerCase() !== "global.env")
298
+ .map((item) => path.join(telegramBotEntriesDir, item.name));
299
+ const filePath = fileCandidates.find((candidatePath) => {
300
+ const stem = path.basename(candidatePath, path.extname(candidatePath)).toLowerCase();
301
+ if (stem === requested) return true;
302
+ const parsedCandidate = parseSimpleEnvText(fs.readFileSync(candidatePath, "utf8"));
303
+ const serverName = String(parsedCandidate.TELEGRAM_BOT_SERVER_NAME || "").trim().toLowerCase();
304
+ const username = String(parsedCandidate.TELEGRAM_BOT_USERNAME || "").trim().toLowerCase();
305
+ return serverName === requested || username === requested;
306
+ });
307
+ if (!filePath || !fs.existsSync(filePath)) {
297
308
  return {};
298
309
  }
299
310
  return parseSimpleEnvText(fs.readFileSync(filePath, "utf8"));
@@ -38,6 +38,7 @@ export function buildSetupOutputLines(summary, deps) {
38
38
  );
39
39
  }
40
40
  }
41
+ lines.push("Telegram bots: per-bot files are created later by bot add / bot verify after server bot identity is known.");
41
42
  if (runnerTemplate?.error) {
42
43
  lines.push(`Runner: template unavailable (${runnerTemplate.error})`);
43
44
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metheus-governance-mcp-cli",
3
- "version": "0.2.102",
3
+ "version": "0.2.103",
4
4
  "description": "Metheus Governance MCP CLI (setup + stdio proxy)",
5
5
  "type": "module",
6
6
  "files": [