switchroom 0.14.44 → 0.14.46

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.
@@ -30596,7 +30596,7 @@ function runInlinedSecretChecks(_config, deps = {}) {
30596
30596
  }
30597
30597
  let parsed;
30598
30598
  try {
30599
- parsed = import_yaml13.parse(raw);
30599
+ parsed = import_yaml14.parse(raw);
30600
30600
  } catch {
30601
30601
  return [
30602
30602
  {
@@ -30624,9 +30624,9 @@ function runInlinedSecretChecks(_config, deps = {}) {
30624
30624
  fix: `Move it to the per-agent-ACL'd vault: \`switchroom vault set <name>\` ` + `then replace the literal with \`vault:<name>\`. Rotate the exposed ` + `value if any agent may already have read it.`
30625
30625
  }));
30626
30626
  }
30627
- var import_yaml13, SECRET_KEY_EXACT;
30627
+ var import_yaml14, SECRET_KEY_EXACT;
30628
30628
  var init_doctor_inlined_secrets = __esm(() => {
30629
- import_yaml13 = __toESM(require_dist(), 1);
30629
+ import_yaml14 = __toESM(require_dist(), 1);
30630
30630
  SECRET_KEY_EXACT = new Set([
30631
30631
  "bot_token",
30632
30632
  "client_secret",
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.44";
49466
- var COMMIT_SHA = "90836113";
49465
+ var VERSION = "0.14.46";
49466
+ var COMMIT_SHA = "df3deacf";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
@@ -72684,6 +72684,47 @@ ${childIndent}approvalAuth: ${value}`;
72684
72684
  return { kind: "rewritten", content: rewritten };
72685
72685
  }
72686
72686
 
72687
+ // src/cli/supergroup-setup-yaml.ts
72688
+ var import_yaml13 = __toESM(require_dist(), 1);
72689
+ function isValidSupergroupChatId(value) {
72690
+ return /^-\d+$/.test(value);
72691
+ }
72692
+ function setAgentSupergroupChatId(yamlText, agentName, chatId) {
72693
+ if (!isValidSupergroupChatId(chatId)) {
72694
+ throw new Error(`setAgentSupergroupChatId: chat_id must be a negative integer string (got "${chatId}")`);
72695
+ }
72696
+ const doc = import_yaml13.parseDocument(yamlText);
72697
+ const root = doc.contents;
72698
+ if (!import_yaml13.isMap(root)) {
72699
+ throw new Error("setAgentSupergroupChatId: YAML root is not a map");
72700
+ }
72701
+ const agents = root.get("agents", true);
72702
+ if (!import_yaml13.isMap(agents)) {
72703
+ throw new Error("setAgentSupergroupChatId: config has no `agents:` map");
72704
+ }
72705
+ const agent = agents.get(agentName, true);
72706
+ if (!import_yaml13.isMap(agent)) {
72707
+ throw new Error(`setAgentSupergroupChatId: agent "${agentName}" not found in config`);
72708
+ }
72709
+ const channels = agent.get("channels", true);
72710
+ if (import_yaml13.isMap(channels)) {
72711
+ const telegram = channels.get("telegram", true);
72712
+ if (import_yaml13.isMap(telegram)) {
72713
+ if (telegram.get("chat_id") === chatId)
72714
+ return yamlText;
72715
+ telegram.set("chat_id", chatId);
72716
+ } else {
72717
+ channels.set("telegram", doc.createNode({ chat_id: chatId }));
72718
+ }
72719
+ } else {
72720
+ agent.set("channels", doc.createNode({ telegram: { chat_id: chatId } }));
72721
+ }
72722
+ const out = String(doc);
72723
+ return out.endsWith(`
72724
+ `) ? out : out + `
72725
+ `;
72726
+ }
72727
+
72687
72728
  // src/cli/setup.ts
72688
72729
  var STEP_PENDING = source_default.gray("\u25cb");
72689
72730
  var STEP_ACTIVE = source_default.blue("->");
@@ -72711,6 +72752,7 @@ function registerSetupCommand(program3) {
72711
72752
  if (userId && userId !== "0") {
72712
72753
  saveUserConfig(userId);
72713
72754
  }
72755
+ await stepSupergroupMode(agentBots, switchroomConfigPath, nonInteractive);
72714
72756
  const forumChatId = "0";
72715
72757
  await stepCreateTopics(config, botToken, nonInteractive);
72716
72758
  await stepMemoryBackend(config, nonInteractive, switchroomConfigPath);
@@ -72966,6 +73008,59 @@ async function stepDmPairing(agentBots, nonInteractive, userIdFlag) {
72966
73008
  return { userId: manualId || "0", chatId: 0 };
72967
73009
  }
72968
73010
  }
73011
+ var SUPERGROUP_DOC = "docs/supergroup-mode.md";
73012
+ async function stepSupergroupMode(agentBots, configPath, nonInteractive) {
73013
+ stepHeader(4, "Supergroup mode (optional)", STEP_ACTIVE);
73014
+ if (nonInteractive) {
73015
+ console.log(source_default.gray(` ${STEP_DONE} Skipped (DM-only default). To have one agent own a forum`));
73016
+ console.log(source_default.gray(` supergroup, see ${SUPERGROUP_DOC}.`));
73017
+ return;
73018
+ }
73019
+ console.log(source_default.gray(" Most setups stay DM-only \u2014 each agent DMs you privately. Supergroup"));
73020
+ console.log(source_default.gray(" mode instead has ONE agent own a Telegram forum supergroup and route"));
73021
+ console.log(source_default.gray(" its replies + automated events into per-topic threads. Opt-in."));
73022
+ const want = await askYesNo(" Configure an agent to own a forum supergroup now?", false);
73023
+ if (!want) {
73024
+ console.log(source_default.gray(` ${STEP_DONE} Staying DM-only. Enable later via ${SUPERGROUP_DOC}.`));
73025
+ return;
73026
+ }
73027
+ const agentNames = Object.keys(agentBots);
73028
+ if (agentNames.length === 0) {
73029
+ console.log(source_default.yellow(` No agents configured yet \u2014 skipping. See ${SUPERGROUP_DOC}.`));
73030
+ return;
73031
+ }
73032
+ const agent = agentNames.length === 1 ? agentNames[0] : await askChoice(" Which agent owns the supergroup?", agentNames);
73033
+ console.log(source_default.gray(" First create the forum supergroup in Telegram (a group with Topics"));
73034
+ console.log(source_default.gray(" enabled) and add the agent's bot as an admin. Its id is the digits in"));
73035
+ console.log(source_default.gray(" a message link t.me/c/<id>/\u2026 written as a negative -100\u2026 number."));
73036
+ const chatId = await ask(" Supergroup chat_id (e.g. -1001234567890, or Enter to skip)");
73037
+ if (!chatId) {
73038
+ console.log(source_default.gray(` ${STEP_DONE} Skipped \u2014 no chat_id entered. See ${SUPERGROUP_DOC}.`));
73039
+ return;
73040
+ }
73041
+ if (!isValidSupergroupChatId(chatId)) {
73042
+ console.log(source_default.yellow(` "${chatId}" isn't a valid supergroup id (need a negative integer like`));
73043
+ console.log(source_default.yellow(` -1001234567890). Add it by hand later \u2014 ${SUPERGROUP_DOC}.`));
73044
+ return;
73045
+ }
73046
+ try {
73047
+ const fs4 = await import("node:fs");
73048
+ const { atomicWriteFileSync: atomicWriteFileSync3 } = await Promise.resolve().then(() => (init_atomic(), exports_atomic));
73049
+ const raw = fs4.readFileSync(configPath, "utf-8");
73050
+ const after = setAgentSupergroupChatId(raw, agent, chatId);
73051
+ let mode = 420;
73052
+ try {
73053
+ mode = fs4.statSync(configPath).mode & 511;
73054
+ } catch {}
73055
+ atomicWriteFileSync3(configPath, after, mode);
73056
+ console.log(source_default.green(` ${STEP_DONE} ${agent} now owns supergroup ${chatId} \u2014 replies + ops route to its topics.`));
73057
+ console.log(source_default.gray(" Fallback topic defaults to General (1). Add default_topic_id /"));
73058
+ console.log(source_default.gray(` topic_aliases by hand \u2014 ${SUPERGROUP_DOC}. Run \`switchroom apply\` to activate.`));
73059
+ } catch (err) {
73060
+ console.log(source_default.yellow(` Could not write chat_id: ${err.message}`));
73061
+ console.log(source_default.gray(` Add it by hand \u2014 see ${SUPERGROUP_DOC}.`));
73062
+ }
73063
+ }
72969
73064
  async function stepCreateTopics(config, botToken, nonInteractive) {
72970
73065
  stepHeader(5, "Create topics", STEP_ACTIVE);
72971
73066
  if (config.telegram.forum_chat_id === "0") {
@@ -73143,16 +73238,16 @@ async function stepScaffoldAgents(config, agentBots, userId, nonInteractive, swi
73143
73238
  }
73144
73239
  async function ensureAuthActiveDefault(configPath) {
73145
73240
  const fs4 = await import("node:fs");
73146
- const { parseDocument: parseDocument6, isMap: isMap6 } = await Promise.resolve().then(() => __toESM(require_dist(), 1));
73241
+ const { parseDocument: parseDocument7, isMap: isMap7 } = await Promise.resolve().then(() => __toESM(require_dist(), 1));
73147
73242
  const { atomicWriteFileSync: atomicWriteFileSync3 } = await Promise.resolve().then(() => (init_atomic(), exports_atomic));
73148
73243
  const { setAuthActive: setAuthActive2 } = await Promise.resolve().then(() => (init_auth_active_yaml(), exports_auth_active_yaml));
73149
73244
  const raw = fs4.readFileSync(configPath, "utf-8");
73150
- const doc = parseDocument6(raw);
73245
+ const doc = parseDocument7(raw);
73151
73246
  const root = doc.contents;
73152
- if (!isMap6(root))
73247
+ if (!isMap7(root))
73153
73248
  return;
73154
73249
  const existing = root.get("auth", true);
73155
- if (isMap6(existing) && existing.has("active"))
73250
+ if (isMap7(existing) && existing.has("active"))
73156
73251
  return;
73157
73252
  const after = setAuthActive2(raw, "default");
73158
73253
  if (after === raw)
@@ -78222,6 +78317,32 @@ agents:
78222
78317
  # admin: true
78223
78318
  # system_prompt_append: |
78224
78319
  # You are the fleet admin agent. Always respond concisely.
78320
+
78321
+ # \u2500\u2500 Supergroup mode (advanced, opt-in) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
78322
+ # By default every agent DMs you privately. Supergroup mode instead
78323
+ # has ONE agent OWN a Telegram forum supergroup: its replies and its
78324
+ # automated events (boot, alerts, cron, approvals) route into per-
78325
+ # topic threads inside that one group, so a busy agent's work stays
78326
+ # organised by subject instead of flooding a single DM.
78327
+ #
78328
+ # \`switchroom setup\` offers this as an optional step, or add it by
78329
+ # hand below. Setting \`chat_id\` is the minimum \u2014 \`default_topic_id\`
78330
+ # auto-defaults to General (topic 1). Create the forum supergroup in
78331
+ # Telegram first, add the bot as an admin, and find the numeric id
78332
+ # (the digits after t.me/c/ in a message link, written as -100\u2026).
78333
+ # Full guide: docs/supergroup-mode.md.
78334
+ #
78335
+ # social:
78336
+ # topic_name: "Social"
78337
+ # bot_token: "vault:telegram-social-bot-token" # its own bot
78338
+ # channels:
78339
+ # telegram:
78340
+ # chat_id: "-1001234567890" # the forum supergroup this agent owns
78341
+ # default_topic_id: 1 # fallback topic (1 = General); optional
78342
+ # topic_aliases: # name \u2192 topic id, for cron + ops routing
78343
+ # alerts: 12 # boot / watchdog / compaction events
78344
+ # admin: 7 # vault / permission / mutation events
78345
+ # planning: 3
78225
78346
  `;
78226
78347
 
78227
78348
  // examples/minimal.yaml
@@ -79839,7 +79960,7 @@ function registerAgentConfigCommands(program3) {
79839
79960
  }
79840
79961
 
79841
79962
  // src/cli/agent-config-write.ts
79842
- var import_yaml15 = __toESM(require_dist(), 1);
79963
+ var import_yaml16 = __toESM(require_dist(), 1);
79843
79964
 
79844
79965
  // src/config/overlay-writer.ts
79845
79966
  init_paths();
@@ -80032,7 +80153,7 @@ function filterOverlaySecrets(doc, source) {
80032
80153
  // src/agents/reconcile-dry-run.ts
80033
80154
  init_overlay_schema();
80034
80155
  init_schema();
80035
- var import_yaml14 = __toESM(require_dist(), 1);
80156
+ var import_yaml15 = __toESM(require_dist(), 1);
80036
80157
  init_lifecycle();
80037
80158
  var MIN_CRON_INTERVAL_SECS = 5 * 60;
80038
80159
  function violatesMinInterval(cron) {
@@ -80054,7 +80175,7 @@ function violatesMinInterval(cron) {
80054
80175
  function dryRunReconcile(input) {
80055
80176
  let parsed;
80056
80177
  try {
80057
- parsed = import_yaml14.parse(input.yamlText);
80178
+ parsed = import_yaml15.parse(input.yamlText);
80058
80179
  } catch (err) {
80059
80180
  return {
80060
80181
  ok: false,
@@ -80360,7 +80481,7 @@ function scheduleAdd(opts) {
80360
80481
  ]
80361
80482
  };
80362
80483
  const yamlText = (() => {
80363
- const body = import_yaml15.stringify(doc);
80484
+ const body = import_yaml16.stringify(doc);
80364
80485
  const header = opts.name ? `# name: ${opts.name}
80365
80486
  ` : "";
80366
80487
  return header + body;
@@ -80470,7 +80591,7 @@ function scheduleAddOrStage(opts) {
80470
80591
  ]
80471
80592
  };
80472
80593
  const yamlText = (opts.name ? `# name: ${opts.name}
80473
- ` : "") + import_yaml15.stringify(doc);
80594
+ ` : "") + import_yaml16.stringify(doc);
80474
80595
  const summary = (() => {
80475
80596
  const parts = [`cron=${opts.cronExpr}`];
80476
80597
  if (opts.secrets?.length)
@@ -80520,7 +80641,7 @@ function scheduleRemove(opts) {
80520
80641
  break;
80521
80642
  }
80522
80643
  try {
80523
- const parsed = import_yaml15.parse(e.raw);
80644
+ const parsed = import_yaml16.parse(e.raw);
80524
80645
  if (parsed && parsed.name === opts.name) {
80525
80646
  match = e;
80526
80647
  break;
@@ -80730,10 +80851,10 @@ function registerAgentConfigWriteCommands(program3) {
80730
80851
  }
80731
80852
 
80732
80853
  // src/cli/agent-config-skill-write.ts
80733
- var import_yaml16 = __toESM(require_dist(), 1);
80854
+ var import_yaml17 = __toESM(require_dist(), 1);
80734
80855
  import { existsSync as existsSync78 } from "node:fs";
80735
80856
  init_reconcile_default_skills();
80736
- var import_yaml17 = __toESM(require_dist(), 1);
80857
+ var import_yaml18 = __toESM(require_dist(), 1);
80737
80858
  import { join as join75 } from "node:path";
80738
80859
  var MAX_SKILLS_PER_AGENT = 20;
80739
80860
  var V1_ALLOWED_SOURCE_PREFIX = "bundled:";
@@ -80783,7 +80904,7 @@ function countCurrentSkills(agent, opts) {
80783
80904
  let total = 0;
80784
80905
  for (const e of entries) {
80785
80906
  try {
80786
- const doc = import_yaml17.parse(e.raw);
80907
+ const doc = import_yaml18.parse(e.raw);
80787
80908
  total += (doc?.skills ?? []).length;
80788
80909
  } catch {}
80789
80910
  }
@@ -80813,7 +80934,7 @@ function skillInstall(opts) {
80813
80934
  if (!existsSync78(skillPath)) {
80814
80935
  return err("E_SKILL_NOT_FOUND", `bundled skill not found at ${skillPath}. The operator needs to ` + `place the skill at this path before the agent can opt in.`);
80815
80936
  }
80816
- const yamlText = import_yaml16.stringify({ skills: [skillName] });
80937
+ const yamlText = import_yaml17.stringify({ skills: [skillName] });
80817
80938
  let path8;
80818
80939
  try {
80819
80940
  path8 = writeSkillsOverlayEntry(agent, slug, yamlText, { root: opts.root });
@@ -80992,7 +81113,7 @@ import { dirname as dirname22, join as join76, relative as relative2, resolve as
80992
81113
  import { spawnSync as spawnSync10 } from "node:child_process";
80993
81114
 
80994
81115
  // src/cli/skill-common.ts
80995
- var import_yaml18 = __toESM(require_dist(), 1);
81116
+ var import_yaml19 = __toESM(require_dist(), 1);
80996
81117
  var MAX_FILE_BYTES = 256 * 1024;
80997
81118
  var MAX_SKILL_BYTES = 2 * 1024 * 1024;
80998
81119
  var MAX_FILES_PER_SKILL = 50;
@@ -81096,7 +81217,7 @@ function validateSkillMd(content, expectedName) {
81096
81217
  }
81097
81218
  let parsed;
81098
81219
  try {
81099
- parsed = import_yaml18.parse(fmText);
81220
+ parsed = import_yaml19.parse(fmText);
81100
81221
  } catch (e) {
81101
81222
  return authorErr("E_SKILL_INVALID_FRONTMATTER", `frontmatter is not valid YAML: ${e.message}`);
81102
81223
  }
@@ -82135,7 +82256,7 @@ function registerSkillPersonalCommands(program3) {
82135
82256
 
82136
82257
  // src/cli/skill-search.ts
82137
82258
  init_helpers();
82138
- var import_yaml19 = __toESM(require_dist(), 1);
82259
+ var import_yaml20 = __toESM(require_dist(), 1);
82139
82260
  import { existsSync as existsSync81, readdirSync as readdirSync32, readFileSync as readFileSync65, statSync as statSync31 } from "node:fs";
82140
82261
  import { homedir as homedir45 } from "node:os";
82141
82262
  import { join as join78, resolve as resolve48 } from "node:path";
@@ -82175,7 +82296,7 @@ function readSkillFrontmatter(skillDir) {
82175
82296
  const fmText = rest.slice(0, endIdx);
82176
82297
  let parsed;
82177
82298
  try {
82178
- parsed = import_yaml19.parse(fmText);
82299
+ parsed = import_yaml20.parse(fmText);
82179
82300
  } catch (e) {
82180
82301
  return { error: `yaml parse: ${e.message}` };
82181
82302
  }
@@ -285,3 +285,29 @@ agents:
285
285
  # admin: true
286
286
  # system_prompt_append: |
287
287
  # You are the fleet admin agent. Always respond concisely.
288
+
289
+ # ── Supergroup mode (advanced, opt-in) ───────────────────────────
290
+ # By default every agent DMs you privately. Supergroup mode instead
291
+ # has ONE agent OWN a Telegram forum supergroup: its replies and its
292
+ # automated events (boot, alerts, cron, approvals) route into per-
293
+ # topic threads inside that one group, so a busy agent's work stays
294
+ # organised by subject instead of flooding a single DM.
295
+ #
296
+ # `switchroom setup` offers this as an optional step, or add it by
297
+ # hand below. Setting `chat_id` is the minimum — `default_topic_id`
298
+ # auto-defaults to General (topic 1). Create the forum supergroup in
299
+ # Telegram first, add the bot as an admin, and find the numeric id
300
+ # (the digits after t.me/c/ in a message link, written as -100…).
301
+ # Full guide: docs/supergroup-mode.md.
302
+ #
303
+ # social:
304
+ # topic_name: "Social"
305
+ # bot_token: "vault:telegram-social-bot-token" # its own bot
306
+ # channels:
307
+ # telegram:
308
+ # chat_id: "-1001234567890" # the forum supergroup this agent owns
309
+ # default_topic_id: 1 # fallback topic (1 = General); optional
310
+ # topic_aliases: # name → topic id, for cron + ops routing
311
+ # alerts: 12 # boot / watchdog / compaction events
312
+ # admin: 7 # vault / permission / mutation events
313
+ # planning: 3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.44",
3
+ "version": "0.14.46",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47833,6 +47833,7 @@ function buildVaultGrantApprovedInbound(opts) {
47833
47833
  return {
47834
47834
  type: "inbound",
47835
47835
  chatId: opts.ctx.chat_id,
47836
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47836
47837
  messageId: ts,
47837
47838
  user: "vault-broker",
47838
47839
  userId: 0,
@@ -47841,6 +47842,7 @@ function buildVaultGrantApprovedInbound(opts) {
47841
47842
  meta: {
47842
47843
  source: "vault_grant_approved",
47843
47844
  agent: opts.ctx.agent,
47845
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47844
47846
  key: opts.ctx.key,
47845
47847
  scope: opts.ctx.scope,
47846
47848
  grant_id: opts.grantId,
@@ -47854,6 +47856,7 @@ function buildVaultGrantDeniedInbound(opts) {
47854
47856
  return {
47855
47857
  type: "inbound",
47856
47858
  chatId: opts.ctx.chat_id,
47859
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47857
47860
  messageId: ts,
47858
47861
  user: "vault-broker",
47859
47862
  userId: 0,
@@ -47862,6 +47865,7 @@ function buildVaultGrantDeniedInbound(opts) {
47862
47865
  meta: {
47863
47866
  source: "vault_grant_denied",
47864
47867
  agent: opts.ctx.agent,
47868
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47865
47869
  key: opts.ctx.key,
47866
47870
  scope: opts.ctx.scope,
47867
47871
  stage_id: opts.stageId,
@@ -47874,6 +47878,7 @@ function buildVaultSaveCompletedInbound(opts) {
47874
47878
  return {
47875
47879
  type: "inbound",
47876
47880
  chatId: opts.ctx.chat_id,
47881
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47877
47882
  messageId: ts,
47878
47883
  user: "vault-broker",
47879
47884
  userId: 0,
@@ -47882,6 +47887,7 @@ function buildVaultSaveCompletedInbound(opts) {
47882
47887
  meta: {
47883
47888
  source: "vault_save_completed",
47884
47889
  agent: opts.ctx.agent,
47890
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47885
47891
  key: opts.ctx.key,
47886
47892
  stage_id: opts.stageId,
47887
47893
  operator_id: opts.operatorId
@@ -47893,6 +47899,7 @@ function buildVaultSaveFailedInbound(opts) {
47893
47899
  return {
47894
47900
  type: "inbound",
47895
47901
  chatId: opts.ctx.chat_id,
47902
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47896
47903
  messageId: ts,
47897
47904
  user: "vault-broker",
47898
47905
  userId: 0,
@@ -47901,6 +47908,7 @@ function buildVaultSaveFailedInbound(opts) {
47901
47908
  meta: {
47902
47909
  source: "vault_save_failed",
47903
47910
  agent: opts.ctx.agent,
47911
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47904
47912
  key: opts.ctx.key,
47905
47913
  stage_id: opts.stageId,
47906
47914
  operator_id: opts.operatorId
@@ -47912,6 +47920,7 @@ function buildVaultSaveDiscardedInbound(opts) {
47912
47920
  return {
47913
47921
  type: "inbound",
47914
47922
  chatId: opts.ctx.chat_id,
47923
+ ...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
47915
47924
  messageId: ts,
47916
47925
  user: "vault-broker",
47917
47926
  userId: 0,
@@ -47920,6 +47929,7 @@ function buildVaultSaveDiscardedInbound(opts) {
47920
47929
  meta: {
47921
47930
  source: "vault_save_discarded",
47922
47931
  agent: opts.ctx.agent,
47932
+ ...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
47923
47933
  key: opts.ctx.key,
47924
47934
  stage_id: opts.stageId,
47925
47935
  operator_id: opts.operatorId
@@ -52024,10 +52034,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52024
52034
  }
52025
52035
 
52026
52036
  // ../src/build-info.ts
52027
- var VERSION = "0.14.44";
52028
- var COMMIT_SHA = "90836113";
52029
- var COMMIT_DATE = "2026-06-03T02:30:59Z";
52030
- var LATEST_PR = 2110;
52037
+ var VERSION = "0.14.46";
52038
+ var COMMIT_SHA = "df3deacf";
52039
+ var COMMIT_DATE = "2026-06-03T05:00:14Z";
52040
+ var LATEST_PR = 2116;
52031
52041
  var COMMITS_AHEAD_OF_TAG = 0;
52032
52042
 
52033
52043
  // gateway/boot-version.ts
@@ -54091,13 +54101,15 @@ function emitGatewayOperatorEvent(event) {
54091
54101
  return;
54092
54102
  }
54093
54103
  const opEventTopic = resolveAgentOutboundTopic({ kind: "compact-watchdog" });
54104
+ const opEventSupergroup = resolveAgentSupergroupChatId();
54094
54105
  process.stderr.write(`telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` + (opEventTopic != null ? ` topic=${opEventTopic}` : "") + `
54095
54106
  `);
54096
54107
  for (const chat_id of access.allowFrom) {
54108
+ const opEventThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: opEventTopic, supergroupChatId: opEventSupergroup });
54097
54109
  const opts = {
54098
54110
  parse_mode: "HTML",
54099
54111
  ...renderedKeyboard ? { reply_markup: renderedKeyboard } : {},
54100
- ...opEventTopic != null ? { message_thread_id: opEventTopic } : {}
54112
+ ...opEventThread != null ? { message_thread_id: opEventThread } : {}
54101
54113
  };
54102
54114
  bot.api.sendMessage(chat_id, renderedText, opts).catch((e) => {
54103
54115
  process.stderr.write(`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}
@@ -56081,6 +56093,8 @@ async function executeVaultRequestSave(args) {
56081
56093
  sweepPendingVaultRequestSaves();
56082
56094
  const text = renderVaultRequestSaveCard(pending2, agentSlug);
56083
56095
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56096
+ if (threadId != null)
56097
+ pending2.threadId = threadId;
56084
56098
  const sent = await retryWithThreadFallback(robustApiCall, (tid) => lockedBot.api.sendMessage(chat_id, text, {
56085
56099
  parse_mode: "HTML",
56086
56100
  reply_markup: buildVaultRequestSaveKeyboard(stageId),
@@ -56155,6 +56169,8 @@ async function executeRequestSecret(args) {
56155
56169
  sweepSecretRequests();
56156
56170
  const text = renderSecretRequestCard(pending2);
56157
56171
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56172
+ if (threadId != null)
56173
+ pending2.threadId = threadId;
56158
56174
  const sent = await retryWithThreadFallback(robustApiCall, (tid) => lockedBot.api.sendMessage(chat_id, text, {
56159
56175
  parse_mode: "HTML",
56160
56176
  reply_markup: buildSecretRequestKeyboard(stageId),
@@ -56205,12 +56221,19 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
56205
56221
  const failMsg = {
56206
56222
  type: "inbound",
56207
56223
  chatId: chat_id,
56224
+ ...armed.threadId != null ? { threadId: armed.threadId } : {},
56208
56225
  messageId: fts,
56209
56226
  user: "vault-broker",
56210
56227
  userId: 0,
56211
56228
  ts: fts,
56212
56229
  text: `\u26A0\uFE0F The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
56213
- meta: { source: "secret_provide_failed", agent: armed.agent, key: armed.key, stage_id: armed.stageId }
56230
+ meta: {
56231
+ source: "secret_provide_failed",
56232
+ agent: armed.agent,
56233
+ ...armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {},
56234
+ key: armed.key,
56235
+ stage_id: armed.stageId
56236
+ }
56214
56237
  };
56215
56238
  const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg);
56216
56239
  if (fdelivered)
@@ -56224,6 +56247,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
56224
56247
  const synthetic = {
56225
56248
  type: "inbound",
56226
56249
  chatId: chat_id,
56250
+ ...armed.threadId != null ? { threadId: armed.threadId } : {},
56227
56251
  messageId: ts,
56228
56252
  user: "vault-broker",
56229
56253
  userId: 0,
@@ -56232,6 +56256,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
56232
56256
  meta: {
56233
56257
  source: "secret_provided",
56234
56258
  agent: armed.agent,
56259
+ ...armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {},
56235
56260
  key: armed.key,
56236
56261
  stage_id: armed.stageId
56237
56262
  }
@@ -56265,7 +56290,8 @@ async function handleSecretRequestCallback(ctx, data) {
56265
56290
  key: pending2.key,
56266
56291
  agent: pending2.agent,
56267
56292
  stageId,
56268
- armed_at: Date.now()
56293
+ armed_at: Date.now(),
56294
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
56269
56295
  });
56270
56296
  await ctx.answerCallbackQuery({ text: "Send the value now \u2014 it auto-deletes." }).catch(() => {});
56271
56297
  if (pending2.card_message_id != null) {
@@ -56287,12 +56313,19 @@ async function handleSecretRequestCallback(ctx, data) {
56287
56313
  const synthetic = {
56288
56314
  type: "inbound",
56289
56315
  chatId: pending2.chat_id,
56316
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {},
56290
56317
  messageId: ts,
56291
56318
  user: "vault-broker",
56292
56319
  userId: 0,
56293
56320
  ts,
56294
56321
  text: `\uD83D\uDEAB Operator declined your request for \`vault:${pending2.key}\`. Proceed without it or ask how they'd like to handle the task.`,
56295
- meta: { source: "secret_declined", agent: pending2.agent, key: pending2.key, stage_id: stageId }
56322
+ meta: {
56323
+ source: "secret_declined",
56324
+ agent: pending2.agent,
56325
+ ...pending2.threadId != null ? { message_thread_id: String(pending2.threadId) } : {},
56326
+ key: pending2.key,
56327
+ stage_id: stageId
56328
+ }
56296
56329
  };
56297
56330
  const delivered = ipcServer.sendToAgent(pending2.agent, synthetic);
56298
56331
  if (delivered)
@@ -56393,6 +56426,8 @@ async function executeVaultRequestAccess(args) {
56393
56426
  sweepPendingVaultRequestAccesses();
56394
56427
  const text = renderVaultRequestAccessCard(pending2);
56395
56428
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56429
+ if (threadId != null)
56430
+ pending2.threadId = threadId;
56396
56431
  const sent = await retryWithThreadFallback(robustApiCall, (tid) => lockedBot.api.sendMessage(chat_id, text, {
56397
56432
  parse_mode: "HTML",
56398
56433
  reply_markup: buildVaultRequestAccessKeyboard(stageId),
@@ -57874,7 +57909,7 @@ ${preBlock(write.output)}`;
57874
57909
  }
57875
57910
  }
57876
57911
  }
57877
- bot.api.sendChatAction(chat_id, "typing").catch(() => {});
57912
+ bot.api.sendChatAction(chat_id, "typing", messageThreadId != null ? { message_thread_id: messageThreadId } : {}).catch(() => {});
57878
57913
  const parsedSteer = parseSteerPrefix(text);
57879
57914
  const isSteerPrefix = parsedSteer.steering;
57880
57915
  const parsedQueue = isSteerPrefix ? { queued: false, body: parsedSteer.body } : parseQueuePrefix(text);
@@ -58381,9 +58416,10 @@ function resolveBootChatId(marker, ageMs) {
58381
58416
  };
58382
58417
  }
58383
58418
  const supergroupBootTopic = resolveAgentOutboundTopic({ kind: "boot" });
58419
+ const bootSupergroup = resolveAgentSupergroupChatId();
58384
58420
  const envChat = process.env.SUBAGENT_OWNER_CHAT_ID;
58385
58421
  if (envChat)
58386
- return { chatId: envChat, threadId: supergroupBootTopic, ackMsgId: undefined };
58422
+ return { chatId: envChat, threadId: topicForRecipient({ recipientChatId: envChat, resolvedTopic: supergroupBootTopic, supergroupChatId: bootSupergroup }), ackMsgId: undefined };
58387
58423
  if (HISTORY_ENABLED) {
58388
58424
  try {
58389
58425
  const access = loadAccess();
@@ -58391,7 +58427,7 @@ function resolveBootChatId(marker, ageMs) {
58391
58427
  if (ownerChatId) {
58392
58428
  const recent = query({ chat_id: ownerChatId, limit: 1 });
58393
58429
  if (recent.length > 0)
58394
- return { chatId: ownerChatId, threadId: supergroupBootTopic, ackMsgId: undefined };
58430
+ return { chatId: ownerChatId, threadId: topicForRecipient({ recipientChatId: ownerChatId, resolvedTopic: supergroupBootTopic, supergroupChatId: bootSupergroup }), ackMsgId: undefined };
58395
58431
  }
58396
58432
  } catch {}
58397
58433
  }
@@ -60025,7 +60061,8 @@ async function performVaultAccessApproval(ctx, pending2, stageId, senderId, atte
60025
60061
  key: pending2.key,
60026
60062
  scope: pending2.scope,
60027
60063
  chat_id: pending2.chat_id,
60028
- ttl_seconds: pending2.ttl_seconds
60064
+ ttl_seconds: pending2.ttl_seconds,
60065
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
60029
60066
  },
60030
60067
  grantId: id,
60031
60068
  stageId,
@@ -60074,7 +60111,8 @@ async function handleVaultRequestAccessCallback(ctx, data) {
60074
60111
  key: pending2.key,
60075
60112
  scope: pending2.scope,
60076
60113
  chat_id: pending2.chat_id,
60077
- ttl_seconds: pending2.ttl_seconds
60114
+ ttl_seconds: pending2.ttl_seconds,
60115
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
60078
60116
  },
60079
60117
  stageId,
60080
60118
  operatorId: senderId
@@ -60169,7 +60207,12 @@ async function handleVaultRequestSaveCallback(ctx, data) {
60169
60207
  await ctx.api.editMessageText(pending2.chat_id, pending2.card_message_id, `\uD83D\uDEAB <i>Discarded. The secret was not written to the vault.</i>`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
60170
60208
  }
60171
60209
  const discardInbound = buildVaultSaveDiscardedInbound({
60172
- ctx: { agent: pending2.agent, key: pending2.key, chat_id: pending2.chat_id },
60210
+ ctx: {
60211
+ agent: pending2.agent,
60212
+ key: pending2.key,
60213
+ chat_id: pending2.chat_id,
60214
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
60215
+ },
60173
60216
  stageId,
60174
60217
  operatorId: senderId
60175
60218
  });
@@ -60230,7 +60273,12 @@ async function handleVaultRequestSaveCallback(ctx, data) {
60230
60273
  const failReason = (write.output || "vault write error").split(`
60231
60274
  `)[0].slice(0, 200);
60232
60275
  const failInbound = buildVaultSaveFailedInbound({
60233
- ctx: { agent: pending2.agent, key: pending2.key, chat_id: pending2.chat_id },
60276
+ ctx: {
60277
+ agent: pending2.agent,
60278
+ key: pending2.key,
60279
+ chat_id: pending2.chat_id,
60280
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
60281
+ },
60234
60282
  stageId,
60235
60283
  operatorId: senderId,
60236
60284
  reason: failReason
@@ -60250,7 +60298,12 @@ async function handleVaultRequestSaveCallback(ctx, data) {
60250
60298
  <i>The agent can now reference this as <code>vault:${escapeHtmlForTg(pending2.key)}</code>.</i>`, { parse_mode: "HTML", reply_markup: { inline_keyboard: [] } }).catch(() => {});
60251
60299
  }
60252
60300
  const okInbound = buildVaultSaveCompletedInbound({
60253
- ctx: { agent: pending2.agent, key: pending2.key, chat_id: pending2.chat_id },
60301
+ ctx: {
60302
+ agent: pending2.agent,
60303
+ key: pending2.key,
60304
+ chat_id: pending2.chat_id,
60305
+ ...pending2.threadId != null ? { threadId: pending2.threadId } : {}
60306
+ },
60254
60307
  stageId,
60255
60308
  operatorId: senderId
60256
60309
  });
@@ -2913,6 +2913,10 @@ interface PendingVaultRequestSave {
2913
2913
  chat_id: string
2914
2914
  /** Card message id (filled in after we send the card). */
2915
2915
  card_message_id?: number
2916
+ /** Supergroup forum topic the agent was working in when it requested the
2917
+ * save — carried into the save-outcome inbound so the resumed reply lands
2918
+ * back in that topic, not General. */
2919
+ threadId?: number
2916
2920
  /** Currently-suggested slug; may be renamed by the user. */
2917
2921
  key: string
2918
2922
  /** Storage shape — 'string' (default) or 'binary'. */
@@ -2954,6 +2958,10 @@ interface PendingVaultRequestAccess {
2954
2958
  chat_id: string
2955
2959
  /** Card message id (filled in after we send the card). */
2956
2960
  card_message_id?: number
2961
+ /** Supergroup forum topic the agent was working in when it requested (the
2962
+ * card's originating thread). Carried into the grant-outcome inbound so the
2963
+ * resumed reply lands back in that topic, not General. */
2964
+ threadId?: number
2957
2965
  /** Vault key the agent wants to read. */
2958
2966
  key: string
2959
2967
  /** 'read' (default) or 'write'. */
@@ -3537,18 +3545,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
3537
3545
  // Fleet-shared / DM agents see `undefined` → no `message_thread_id`
3538
3546
  // is added to the broadcast opts → behavior unchanged.
3539
3547
  const opEventTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
3548
+ const opEventSupergroup = resolveAgentSupergroupChatId()
3540
3549
 
3541
3550
  process.stderr.write(
3542
3551
  `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
3543
3552
  (opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
3544
3553
  )
3545
3554
  for (const chat_id of access.allowFrom) {
3555
+ // The resolved topic is valid ONLY in the agent's supergroup — attaching
3556
+ // it to an operator DM recipient yields 400 "message thread not found" and
3557
+ // the event silently fails to deliver (the marko #2096 class). Guard it:
3558
+ // DM recipients get a thread-less send; the supergroup owner gets the lane.
3559
+ const opEventThread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: opEventTopic, supergroupChatId: opEventSupergroup })
3546
3560
  // grammy's Other<...> opts type is generated and stricter than our
3547
3561
  // call shape; runtime accepts both. Cast through unknown.
3548
3562
  const opts = {
3549
3563
  parse_mode: 'HTML' as const,
3550
3564
  ...(renderedKeyboard ? { reply_markup: renderedKeyboard } : {}),
3551
- ...(opEventTopic != null ? { message_thread_id: opEventTopic } : {}),
3565
+ ...(opEventThread != null ? { message_thread_id: opEventThread } : {}),
3552
3566
  }
3553
3567
  // Comment-only context for the reader; the lint marker on the
3554
3568
  // very next line is what unlocks the raw bot.api call.
@@ -7074,6 +7088,8 @@ async function executeVaultRequestSave(args: Record<string, unknown>): Promise<{
7074
7088
  // crashing the tool call.
7075
7089
  const text = renderVaultRequestSaveCard(pending, agentSlug)
7076
7090
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7091
+ // Remember the agent's working topic so the save-outcome inbound resumes in it.
7092
+ if (threadId != null) pending.threadId = threadId
7077
7093
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7078
7094
  robustApiCall,
7079
7095
  (tid) =>
@@ -7114,12 +7130,16 @@ interface PendingSecretRequest {
7114
7130
  reason?: string
7115
7131
  staged_at: number
7116
7132
  card_message_id?: number
7133
+ /** Supergroup forum topic the agent was working in — carried into the
7134
+ * provide/decline/fail outcome inbounds so the resumed reply lands back
7135
+ * in that topic, not General. */
7136
+ threadId?: number
7117
7137
  }
7118
7138
  // stageId -> request (lives until tapped or TTL).
7119
7139
  const pendingSecretRequests = new Map<string, PendingSecretRequest>()
7120
7140
  // chat_id -> the armed capture: the operator's NEXT message in this chat is
7121
7141
  // the value for `key`. Set when [Provide securely] is tapped.
7122
- interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number }
7142
+ interface ArmedSecretCapture { key: string; agent: string; stageId: string; armed_at: number; threadId?: number }
7123
7143
  const armedSecretCaptures = new Map<string, ArmedSecretCapture>()
7124
7144
  const PENDING_SECRET_REQUEST_TTL_MS = 30 * 60_000 // card lifetime
7125
7145
  const ARMED_SECRET_CAPTURE_TTL_MS = 10 * 60_000 // window to send the value after tapping
@@ -7188,6 +7208,8 @@ async function executeRequestSecret(args: Record<string, unknown>): Promise<{ co
7188
7208
 
7189
7209
  const text = renderSecretRequestCard(pending)
7190
7210
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7211
+ // Remember the agent's working topic so the provide/decline/fail inbound resumes in it.
7212
+ if (threadId != null) pending.threadId = threadId
7191
7213
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7192
7214
  robustApiCall,
7193
7215
  (tid) =>
@@ -7263,12 +7285,19 @@ async function captureProvidedSecret(
7263
7285
  const failMsg: InboundMessage = {
7264
7286
  type: 'inbound',
7265
7287
  chatId: chat_id,
7288
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7266
7289
  messageId: fts,
7267
7290
  user: 'vault-broker',
7268
7291
  userId: 0,
7269
7292
  ts: fts,
7270
7293
  text: `⚠️ The secret you requested for \`vault:${armed.key}\` could NOT be saved (vault write failed). Do not assume it is available; tell the operator or try request_secret again.`,
7271
- meta: { source: 'secret_provide_failed', agent: armed.agent, key: armed.key, stage_id: armed.stageId },
7294
+ meta: {
7295
+ source: 'secret_provide_failed',
7296
+ agent: armed.agent,
7297
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7298
+ key: armed.key,
7299
+ stage_id: armed.stageId,
7300
+ },
7272
7301
  }
7273
7302
  const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
7274
7303
  if (fdelivered) markClaudeBusyForInbound(failMsg)
@@ -7288,6 +7317,7 @@ async function captureProvidedSecret(
7288
7317
  const synthetic: InboundMessage = {
7289
7318
  type: 'inbound',
7290
7319
  chatId: chat_id,
7320
+ ...(armed.threadId != null ? { threadId: armed.threadId } : {}),
7291
7321
  messageId: ts,
7292
7322
  user: 'vault-broker',
7293
7323
  userId: 0,
@@ -7299,6 +7329,7 @@ async function captureProvidedSecret(
7299
7329
  meta: {
7300
7330
  source: 'secret_provided',
7301
7331
  agent: armed.agent,
7332
+ ...(armed.threadId != null ? { message_thread_id: String(armed.threadId) } : {}),
7302
7333
  key: armed.key,
7303
7334
  stage_id: armed.stageId,
7304
7335
  },
@@ -7339,6 +7370,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7339
7370
  agent: pending.agent,
7340
7371
  stageId,
7341
7372
  armed_at: Date.now(),
7373
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7342
7374
  })
7343
7375
  await ctx.answerCallbackQuery({ text: 'Send the value now — it auto-deletes.' }).catch(() => {})
7344
7376
  if (pending.card_message_id != null) {
@@ -7371,12 +7403,19 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
7371
7403
  const synthetic: InboundMessage = {
7372
7404
  type: 'inbound',
7373
7405
  chatId: pending.chat_id,
7406
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
7374
7407
  messageId: ts,
7375
7408
  user: 'vault-broker',
7376
7409
  userId: 0,
7377
7410
  ts,
7378
7411
  text: `🚫 Operator declined your request for \`vault:${pending.key}\`. Proceed without it or ask how they'd like to handle the task.`,
7379
- meta: { source: 'secret_declined', agent: pending.agent, key: pending.key, stage_id: stageId },
7412
+ meta: {
7413
+ source: 'secret_declined',
7414
+ agent: pending.agent,
7415
+ ...(pending.threadId != null ? { message_thread_id: String(pending.threadId) } : {}),
7416
+ key: pending.key,
7417
+ stage_id: stageId,
7418
+ },
7380
7419
  }
7381
7420
  const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
7382
7421
  if (delivered) markClaudeBusyForInbound(synthetic)
@@ -7524,6 +7563,8 @@ async function executeVaultRequestAccess(args: Record<string, unknown>): Promise
7524
7563
 
7525
7564
  const text = renderVaultRequestAccessCard(pending)
7526
7565
  const threadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
7566
+ // Remember the agent's working topic so the grant-outcome inbound resumes in it.
7567
+ if (threadId != null) pending.threadId = threadId
7527
7568
  // #1075: deleted-topic safe — fall back to main chat.
7528
7569
  const sent = await retryWithThreadFallback<{ message_id: number }>(
7529
7570
  robustApiCall,
@@ -10159,7 +10200,14 @@ async function handleInbound(
10159
10200
  // No staged entry to act on — fall through to normal handling.
10160
10201
  }
10161
10202
 
10162
- void bot.api.sendChatAction(chat_id, 'typing').catch(() => {})
10203
+ // Typing indicator in the ORIGINATING topic — on a supergroup-topic inbound,
10204
+ // an un-threaded sendChatAction shows "typing" in General, not the topic the
10205
+ // user is in. messageThreadId is the inbound's thread (undefined in a DM).
10206
+ void bot.api.sendChatAction(
10207
+ chat_id,
10208
+ 'typing',
10209
+ messageThreadId != null ? { message_thread_id: messageThreadId } : {},
10210
+ ).catch(() => {})
10163
10211
 
10164
10212
  // Parse explicit prefixes first. `/steer ` / `/s ` opts IN to steering;
10165
10213
  // `/queue ` / `/q ` are legacy aliases that opt in to the new default (queued).
@@ -11088,10 +11136,14 @@ function resolveBootChatId(
11088
11136
  // → behavior unchanged (lands at chat-root as today). PR4b of
11089
11137
  // supergroup-mode rollout (docs/rfcs/supergroup-mode.md).
11090
11138
  const supergroupBootTopic = resolveAgentOutboundTopic({ kind: 'boot' })
11139
+ const bootSupergroup = resolveAgentSupergroupChatId()
11140
+ // The boot topic is valid only in the agent's supergroup — attach it per
11141
+ // recipient so a DM owner doesn't 400 (marko #2096 class); the supergroup
11142
+ // owner gets the boot/alerts lane, a DM gets a thread-less boot card.
11091
11143
 
11092
11144
  // 2. Env var
11093
11145
  const envChat = process.env.SUBAGENT_OWNER_CHAT_ID
11094
- if (envChat) return { chatId: envChat, threadId: supergroupBootTopic, ackMsgId: undefined }
11146
+ if (envChat) return { chatId: envChat, threadId: topicForRecipient({ recipientChatId: envChat, resolvedTopic: supergroupBootTopic, supergroupChatId: bootSupergroup }), ackMsgId: undefined }
11095
11147
  // 3. Most-recent inbound from history
11096
11148
  if (HISTORY_ENABLED) {
11097
11149
  try {
@@ -11099,7 +11151,7 @@ function resolveBootChatId(
11099
11151
  const ownerChatId = access.allowFrom[0]
11100
11152
  if (ownerChatId) {
11101
11153
  const recent = queryHistory({ chat_id: ownerChatId, limit: 1 })
11102
- if (recent.length > 0) return { chatId: ownerChatId, threadId: supergroupBootTopic, ackMsgId: undefined }
11154
+ if (recent.length > 0) return { chatId: ownerChatId, threadId: topicForRecipient({ recipientChatId: ownerChatId, resolvedTopic: supergroupBootTopic, supergroupChatId: bootSupergroup }), ackMsgId: undefined }
11103
11155
  }
11104
11156
  } catch {}
11105
11157
  }
@@ -14006,6 +14058,7 @@ async function performVaultAccessApproval(
14006
14058
  scope: pending.scope,
14007
14059
  chat_id: pending.chat_id,
14008
14060
  ttl_seconds: pending.ttl_seconds,
14061
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14009
14062
  },
14010
14063
  grantId: id,
14011
14064
  stageId,
@@ -14087,6 +14140,7 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
14087
14140
  scope: pending.scope,
14088
14141
  chat_id: pending.chat_id,
14089
14142
  ttl_seconds: pending.ttl_seconds,
14143
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14090
14144
  },
14091
14145
  stageId,
14092
14146
  operatorId: senderId,
@@ -14261,7 +14315,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14261
14315
  // tool returned "waiting for operator", the turn ended, and a
14262
14316
  // Discard left the agent silently idle forever.
14263
14317
  const discardInbound = buildVaultSaveDiscardedInbound({
14264
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14318
+ ctx: {
14319
+ agent: pending.agent,
14320
+ key: pending.key,
14321
+ chat_id: pending.chat_id,
14322
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14323
+ },
14265
14324
  stageId,
14266
14325
  operatorId: senderId,
14267
14326
  })
@@ -14384,7 +14443,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14384
14443
  const failReason =
14385
14444
  (write.output || 'vault write error').split('\n')[0]!.slice(0, 200)
14386
14445
  const failInbound = buildVaultSaveFailedInbound({
14387
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14446
+ ctx: {
14447
+ agent: pending.agent,
14448
+ key: pending.key,
14449
+ chat_id: pending.chat_id,
14450
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14451
+ },
14388
14452
  stageId,
14389
14453
  operatorId: senderId,
14390
14454
  reason: failReason,
@@ -14415,7 +14479,12 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
14415
14479
  // task that was blocked on this credential (symmetric with the
14416
14480
  // vra: approve path; buffered if the bridge is mid-reconnect).
14417
14481
  const okInbound = buildVaultSaveCompletedInbound({
14418
- ctx: { agent: pending.agent, key: pending.key, chat_id: pending.chat_id },
14482
+ ctx: {
14483
+ agent: pending.agent,
14484
+ key: pending.key,
14485
+ chat_id: pending.chat_id,
14486
+ ...(pending.threadId != null ? { threadId: pending.threadId } : {}),
14487
+ },
14419
14488
  stageId,
14420
14489
  operatorId: senderId,
14421
14490
  })
@@ -34,6 +34,10 @@ export interface VaultGrantInboundContext {
34
34
  chat_id: string
35
35
  /** Seconds. For approved grants; ignored for deny. */
36
36
  ttl_seconds: number
37
+ /** Supergroup forum topic (message_thread_id) the agent was working in
38
+ * when it requested the credential — so the resumed turn's reply lands
39
+ * back in that topic, not General. Undefined for DM / non-topic requests. */
40
+ threadId?: number
37
41
  }
38
42
 
39
43
  /**
@@ -62,6 +66,7 @@ export function buildVaultGrantApprovedInbound(opts: {
62
66
  return {
63
67
  type: 'inbound',
64
68
  chatId: opts.ctx.chat_id,
69
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
65
70
  messageId: ts, // synthetic — no Telegram message id exists
66
71
  user: 'vault-broker',
67
72
  userId: 0,
@@ -76,6 +81,7 @@ export function buildVaultGrantApprovedInbound(opts: {
76
81
  meta: {
77
82
  source: 'vault_grant_approved',
78
83
  agent: opts.ctx.agent,
84
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
79
85
  key: opts.ctx.key,
80
86
  scope: opts.ctx.scope,
81
87
  grant_id: opts.grantId,
@@ -103,6 +109,7 @@ export function buildVaultGrantDeniedInbound(opts: {
103
109
  return {
104
110
  type: 'inbound',
105
111
  chatId: opts.ctx.chat_id,
112
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
106
113
  messageId: ts,
107
114
  user: 'vault-broker',
108
115
  userId: 0,
@@ -116,6 +123,7 @@ export function buildVaultGrantDeniedInbound(opts: {
116
123
  meta: {
117
124
  source: 'vault_grant_denied',
118
125
  agent: opts.ctx.agent,
126
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
119
127
  key: opts.ctx.key,
120
128
  scope: opts.ctx.scope,
121
129
  stage_id: opts.stageId,
@@ -133,6 +141,9 @@ export interface VaultSaveInboundContext {
133
141
  /** Telegram chat the save card lived in — keeps the synthesized
134
142
  * resume-turn associated with the originating conversation. */
135
143
  chat_id: string
144
+ /** Supergroup forum topic the agent was working in when it requested the
145
+ * save — so the resumed reply lands in that topic, not General. */
146
+ threadId?: number
136
147
  }
137
148
 
138
149
  /**
@@ -154,6 +165,7 @@ export function buildVaultSaveCompletedInbound(opts: {
154
165
  return {
155
166
  type: 'inbound',
156
167
  chatId: opts.ctx.chat_id,
168
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
157
169
  messageId: ts,
158
170
  user: 'vault-broker',
159
171
  userId: 0,
@@ -165,6 +177,7 @@ export function buildVaultSaveCompletedInbound(opts: {
165
177
  meta: {
166
178
  source: 'vault_save_completed',
167
179
  agent: opts.ctx.agent,
180
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
168
181
  key: opts.ctx.key,
169
182
  stage_id: opts.stageId,
170
183
  operator_id: opts.operatorId,
@@ -187,6 +200,7 @@ export function buildVaultSaveFailedInbound(opts: {
187
200
  return {
188
201
  type: 'inbound',
189
202
  chatId: opts.ctx.chat_id,
203
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
190
204
  messageId: ts,
191
205
  user: 'vault-broker',
192
206
  userId: 0,
@@ -200,6 +214,7 @@ export function buildVaultSaveFailedInbound(opts: {
200
214
  meta: {
201
215
  source: 'vault_save_failed',
202
216
  agent: opts.ctx.agent,
217
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
203
218
  key: opts.ctx.key,
204
219
  stage_id: opts.stageId,
205
220
  operator_id: opts.operatorId,
@@ -221,6 +236,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
221
236
  return {
222
237
  type: 'inbound',
223
238
  chatId: opts.ctx.chat_id,
239
+ ...(opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {}),
224
240
  messageId: ts,
225
241
  user: 'vault-broker',
226
242
  userId: 0,
@@ -234,6 +250,7 @@ export function buildVaultSaveDiscardedInbound(opts: {
234
250
  meta: {
235
251
  source: 'vault_save_discarded',
236
252
  agent: opts.ctx.agent,
253
+ ...(opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {}),
237
254
  key: opts.ctx.key,
238
255
  stage_id: opts.stageId,
239
256
  operator_id: opts.operatorId,
@@ -133,8 +133,9 @@ export function computeLabel(toolName, input) {
133
133
  }
134
134
  }
135
135
 
136
- // MCP allowlist.
136
+ // MCP tools.
137
137
  if (typeof toolName === 'string' && toolName.startsWith('mcp__')) {
138
+ // Explicit labels / suppressions for the built-in servers.
138
139
  switch (toolName) {
139
140
  case 'mcp__switchroom-telegram__reply':
140
141
  case 'mcp__switchroom-telegram__stream_reply':
@@ -150,15 +151,46 @@ export function computeLabel(toolName, input) {
150
151
  return 'Searching memory'
151
152
  case 'mcp__hindsight__retain':
152
153
  return 'Saving memory'
153
- // Explicit suppressions — return null so we don't emit a sidecar
154
- // line at all. (Falling through to the default below produces the
155
- // same effect, but listing these makes the intent obvious.)
154
+ // Explicit suppressions — return null so we don't emit a sidecar line.
156
155
  case 'mcp__switchroom-telegram__send_typing':
157
156
  case 'mcp__hindsight__sync_retain':
158
157
  return null
159
158
  }
160
- // Any other mcp__* tool: not on the allowlist, no label.
161
- return null
159
+ // Generic fallback for ANY other MCP tool (operator-configured servers
160
+ // — perplexity, webkite, gdrive, notion, …). These previously returned
161
+ // null → invisible in the live activity feed, so a research turn driven
162
+ // entirely by MCP tools read as pure silence (only a typing dot + the
163
+ // 👀 reaction) — the "I can't see what it's doing" report. Mirror the
164
+ // gateway's describeToolUse: friendly per-server labels, else a
165
+ // model-authored field, else a humanized tool name. NEVER label
166
+ // switchroom-telegram surface/control tools (they ARE the conversation).
167
+ const m = /^mcp__(.+?)__(.+)$/.exec(toolName)
168
+ if (!m) return null
169
+ const server = m[1].toLowerCase()
170
+ const tool = m[2].toLowerCase()
171
+ if (server === 'switchroom-telegram') return null
172
+ if (server === 'hindsight') return 'Working with memory'
173
+ if (server === 'google-workspace' || server === 'claude_ai_google_calendar')
174
+ return 'Checking your calendar'
175
+ if (server === 'claude_ai_gmail') return 'Checking your email'
176
+ if (server === 'claude_ai_google_drive' || server === 'gdrive')
177
+ return 'Looking through your files'
178
+ if (server === 'notion' || server === 'claude_ai_notion') return 'Checking your notes'
179
+ if (server === 'perplexity') {
180
+ const q = clip(String(i.query ?? i.description ?? ''), 60).trim()
181
+ return q ? `Searching the web for ${q}` : 'Searching the web'
182
+ }
183
+ if (server === 'webkite') {
184
+ const u = clip(urlHostPath(i.url ?? ''), 60).trim()
185
+ return u ? `Reading ${u}` : 'Reading the web'
186
+ }
187
+ // Unknown MCP server: prefer a model-authored field, else humanized tool.
188
+ const desc =
189
+ clip(String(i.description ?? ''), 60).trim() ||
190
+ clip(String(i.query ?? ''), 50).trim() ||
191
+ clip(String(i.title ?? ''), 50).trim()
192
+ if (desc) return desc
193
+ return `Using ${tool.replace(/[-_]+/g, ' ')}`
162
194
  }
163
195
 
164
196
  return null
@@ -16,7 +16,11 @@ import { describe, it, expect } from 'vitest'
16
16
  import {
17
17
  buildVaultGrantApprovedInbound,
18
18
  buildVaultGrantDeniedInbound,
19
+ buildVaultSaveCompletedInbound,
20
+ buildVaultSaveFailedInbound,
21
+ buildVaultSaveDiscardedInbound,
19
22
  type VaultGrantInboundContext,
23
+ type VaultSaveInboundContext,
20
24
  } from '../gateway/vault-grant-inbound-builders.js'
21
25
 
22
26
  const FIXED_NOW = 1_700_000_000_000
@@ -224,3 +228,83 @@ describe('approve vs deny shape invariants', () => {
224
228
  expect(String(deny.meta?.source)).toMatch(/^vault_grant_denied$/)
225
229
  })
226
230
  })
231
+
232
+ // ── Supergroup topic routing (gap #2) ──────────────────────────────────────
233
+ //
234
+ // When the agent requested the credential from inside a forum topic, the
235
+ // grant/save-outcome inbound must carry that topic so the resumed turn's
236
+ // reply lands back in it — not General. The carrier is two-fold and BOTH
237
+ // halves are load-bearing:
238
+ // - top-level `threadId` → the gateway's per-topic busy-key / deliver-
239
+ // until-acked keying (markClaudeBusyForInbound reads it).
240
+ // - `meta.message_thread_id` (stringified) → rendered into the
241
+ // `<channel message_thread_id="…">` XML, which session-tail's
242
+ // parseChannelMeta re-extracts to set currentTurn.sessionThreadId, which
243
+ // the reply tool defaults to. Drop either and the reply mis-routes.
244
+ //
245
+ // DM / non-topic requests must leave BOTH absent (an empty-string or 0
246
+ // thread is a Telegram 400 "message thread not found").
247
+ describe('grant/save outcome topic routing', () => {
248
+ const SAVE_CTX: VaultSaveInboundContext = {
249
+ agent: 'marko',
250
+ key: 'brevo/api-key',
251
+ chat_id: '-1001234567890',
252
+ }
253
+ const THREAD = 4242
254
+
255
+ const grantBuilders = [
256
+ {
257
+ name: 'approved',
258
+ build: (ctx: VaultGrantInboundContext) =>
259
+ buildVaultGrantApprovedInbound({ ctx, grantId: 'vg_x', stageId: 's', operatorId: '1' }),
260
+ },
261
+ {
262
+ name: 'denied',
263
+ build: (ctx: VaultGrantInboundContext) =>
264
+ buildVaultGrantDeniedInbound({ ctx, stageId: 's', operatorId: '1' }),
265
+ },
266
+ ]
267
+ const saveBuilders = [
268
+ {
269
+ name: 'save-completed',
270
+ build: (ctx: VaultSaveInboundContext) =>
271
+ buildVaultSaveCompletedInbound({ ctx, stageId: 's', operatorId: '1' }),
272
+ },
273
+ {
274
+ name: 'save-failed',
275
+ build: (ctx: VaultSaveInboundContext) =>
276
+ buildVaultSaveFailedInbound({ ctx, stageId: 's', operatorId: '1', reason: 'disk full' }),
277
+ },
278
+ {
279
+ name: 'save-discarded',
280
+ build: (ctx: VaultSaveInboundContext) =>
281
+ buildVaultSaveDiscardedInbound({ ctx, stageId: 's', operatorId: '1' }),
282
+ },
283
+ ]
284
+
285
+ for (const { name, build } of grantBuilders) {
286
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
287
+ const msg = build({ ...CTX_READ, threadId: THREAD })
288
+ expect(msg.threadId).toBe(THREAD)
289
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
290
+ })
291
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
292
+ const msg = build(CTX_READ)
293
+ expect(msg.threadId).toBeUndefined()
294
+ expect(msg.meta?.message_thread_id).toBeUndefined()
295
+ })
296
+ }
297
+
298
+ for (const { name, build } of saveBuilders) {
299
+ it(`${name}: threadId set → top-level threadId + meta.message_thread_id (stringified)`, () => {
300
+ const msg = build({ ...SAVE_CTX, threadId: THREAD })
301
+ expect(msg.threadId).toBe(THREAD)
302
+ expect(msg.meta?.message_thread_id).toBe(String(THREAD))
303
+ })
304
+ it(`${name}: threadId absent → both omitted (DM stays thread-less)`, () => {
305
+ const msg = build(SAVE_CTX)
306
+ expect(msg.threadId).toBeUndefined()
307
+ expect(msg.meta?.message_thread_id).toBeUndefined()
308
+ })
309
+ }
310
+ })