switchroom 0.14.36 → 0.14.38

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.
@@ -11101,18 +11101,11 @@ var TelegramChannelSchema = exports_external.object({
11101
11101
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11102
11102
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11103
11103
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11104
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11104
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11105
11105
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
11106
11106
  }).optional().superRefine((tg, ctx) => {
11107
11107
  if (!tg)
11108
11108
  return;
11109
- if (tg.chat_id != null && tg.default_topic_id == null) {
11110
- ctx.addIssue({
11111
- code: exports_external.ZodIssueCode.custom,
11112
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
11113
- path: ["default_topic_id"]
11114
- });
11115
- }
11116
11109
  if (tg.default_topic_id != null && tg.chat_id == null) {
11117
11110
  ctx.addIssue({
11118
11111
  code: exports_external.ZodIssueCode.custom,
@@ -11127,6 +11120,11 @@ var TelegramChannelSchema = exports_external.object({
11127
11120
  path: ["topic_aliases"]
11128
11121
  });
11129
11122
  }
11123
+ }).transform((tg) => {
11124
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11125
+ return { ...tg, default_topic_id: 1 };
11126
+ }
11127
+ return tg;
11130
11128
  });
11131
11129
  var ChannelsSchema = exports_external.object({
11132
11130
  telegram: TelegramChannelSchema
@@ -11101,18 +11101,11 @@ var TelegramChannelSchema = exports_external.object({
11101
11101
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11102
11102
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11103
11103
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11104
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11104
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11105
11105
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
11106
11106
  }).optional().superRefine((tg, ctx) => {
11107
11107
  if (!tg)
11108
11108
  return;
11109
- if (tg.chat_id != null && tg.default_topic_id == null) {
11110
- ctx.addIssue({
11111
- code: exports_external.ZodIssueCode.custom,
11112
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
11113
- path: ["default_topic_id"]
11114
- });
11115
- }
11116
11109
  if (tg.default_topic_id != null && tg.chat_id == null) {
11117
11110
  ctx.addIssue({
11118
11111
  code: exports_external.ZodIssueCode.custom,
@@ -11127,6 +11120,11 @@ var TelegramChannelSchema = exports_external.object({
11127
11120
  path: ["topic_aliases"]
11128
11121
  });
11129
11122
  }
11123
+ }).transform((tg) => {
11124
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11125
+ return { ...tg, default_topic_id: 1 };
11126
+ }
11127
+ return tg;
11130
11128
  });
11131
11129
  var ChannelsSchema = exports_external.object({
11132
11130
  telegram: TelegramChannelSchema
@@ -11849,18 +11849,11 @@ var TelegramChannelSchema = exports_external.object({
11849
11849
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11850
11850
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge \u2014 the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11851
11851
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID \u2014 overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11852
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11852
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted \u2014 set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11853
11853
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot \u2192 alerts, hostd \u2192 admin, etc.). " + "Cascades per-key through defaults \u2192 profile \u2192 agent.")
11854
11854
  }).optional().superRefine((tg, ctx) => {
11855
11855
  if (!tg)
11856
11856
  return;
11857
- if (tg.chat_id != null && tg.default_topic_id == null) {
11858
- ctx.addIssue({
11859
- code: exports_external.ZodIssueCode.custom,
11860
- message: "`channels.telegram.chat_id` requires `default_topic_id` \u2014 supergroup-mode agents need a fallback topic for unclassified outbounds.",
11861
- path: ["default_topic_id"]
11862
- });
11863
- }
11864
11857
  if (tg.default_topic_id != null && tg.chat_id == null) {
11865
11858
  ctx.addIssue({
11866
11859
  code: exports_external.ZodIssueCode.custom,
@@ -11875,6 +11868,11 @@ var TelegramChannelSchema = exports_external.object({
11875
11868
  path: ["topic_aliases"]
11876
11869
  });
11877
11870
  }
11871
+ }).transform((tg) => {
11872
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11873
+ return { ...tg, default_topic_id: 1 };
11874
+ }
11875
+ return tg;
11878
11876
  });
11879
11877
  var ChannelsSchema = exports_external.object({
11880
11878
  telegram: TelegramChannelSchema
@@ -13665,18 +13665,11 @@ var init_schema = __esm(() => {
13665
13665
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
13666
13666
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge \u2014 the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
13667
13667
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID \u2014 overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
13668
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
13668
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted \u2014 set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
13669
13669
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot \u2192 alerts, hostd \u2192 admin, etc.). " + "Cascades per-key through defaults \u2192 profile \u2192 agent.")
13670
13670
  }).optional().superRefine((tg, ctx) => {
13671
13671
  if (!tg)
13672
13672
  return;
13673
- if (tg.chat_id != null && tg.default_topic_id == null) {
13674
- ctx.addIssue({
13675
- code: exports_external.ZodIssueCode.custom,
13676
- message: "`channels.telegram.chat_id` requires `default_topic_id` \u2014 supergroup-mode agents need a fallback topic for unclassified outbounds.",
13677
- path: ["default_topic_id"]
13678
- });
13679
- }
13680
13673
  if (tg.default_topic_id != null && tg.chat_id == null) {
13681
13674
  ctx.addIssue({
13682
13675
  code: exports_external.ZodIssueCode.custom,
@@ -13691,6 +13684,11 @@ var init_schema = __esm(() => {
13691
13684
  path: ["topic_aliases"]
13692
13685
  });
13693
13686
  }
13687
+ }).transform((tg) => {
13688
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
13689
+ return { ...tg, default_topic_id: 1 };
13690
+ }
13691
+ return tg;
13694
13692
  });
13695
13693
  ChannelsSchema = exports_external.object({
13696
13694
  telegram: TelegramChannelSchema
@@ -23654,7 +23652,7 @@ var init_docker_fleet = __esm(() => {
23654
23652
 
23655
23653
  // src/agents/lifecycle.ts
23656
23654
  import { execFileSync as execFileSync7, spawn, spawnSync } from "node:child_process";
23657
- import { existsSync as existsSync15, mkdirSync as mkdirSync12, writeFileSync as writeFileSync7, renameSync as renameSync3, readFileSync as readFileSync13 } from "node:fs";
23655
+ import { existsSync as existsSync15, mkdirSync as mkdirSync12, writeFileSync as writeFileSync7, renameSync as renameSync4, readFileSync as readFileSync13 } from "node:fs";
23658
23656
  import { resolve as resolve13, join as join10 } from "node:path";
23659
23657
  import { connect } from "node:net";
23660
23658
  function cleanShutdownMarkerPathForAgent(name) {
@@ -23676,7 +23674,7 @@ function writeRestartReasonMarker(name, reason, opts = {}) {
23676
23674
  const marker = { ts: Date.now(), signal: "SIGTERM", reason };
23677
23675
  const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
23678
23676
  writeFileSync7(tmp, JSON.stringify(marker), "utf-8");
23679
- renameSync3(tmp, path);
23677
+ renameSync4(tmp, path);
23680
23678
  } catch {}
23681
23679
  }
23682
23680
  function buildCliRestartReason(opts) {
@@ -24154,7 +24152,7 @@ import {
24154
24152
  mkdirSync as mkdirSync14,
24155
24153
  readFileSync as readFileSync17,
24156
24154
  readdirSync as readdirSync9,
24157
- renameSync as renameSync5,
24155
+ renameSync as renameSync6,
24158
24156
  rmSync as rmSync4,
24159
24157
  statSync as statSync11,
24160
24158
  writeFileSync as writeFileSync9
@@ -24296,7 +24294,7 @@ function atomicCopy(src, dest, mode) {
24296
24294
  const tmp = `${dest}.tmp-${process.pid}-${randomBytes2(4).toString("hex")}`;
24297
24295
  try {
24298
24296
  writeFileSync9(tmp, contents, { mode });
24299
- renameSync5(tmp, dest);
24297
+ renameSync6(tmp, dest);
24300
24298
  } catch (err) {
24301
24299
  try {
24302
24300
  rmSync4(tmp);
@@ -25835,7 +25833,7 @@ import {
25835
25833
  mkdirSync as mkdirSync17,
25836
25834
  readFileSync as readFileSync22,
25837
25835
  readdirSync as readdirSync14,
25838
- renameSync as renameSync6,
25836
+ renameSync as renameSync7,
25839
25837
  rmSync as rmSync10,
25840
25838
  statSync as statSync15,
25841
25839
  writeFileSync as writeFileSync14
@@ -25972,7 +25970,7 @@ __export(exports_atomic, {
25972
25970
  atomicWriteFileSync: () => atomicWriteFileSync2
25973
25971
  });
25974
25972
  import { randomBytes as randomBytes3 } from "node:crypto";
25975
- import { closeSync as closeSync5, constants, fsyncSync as fsyncSync3, openSync as openSync5, renameSync as renameSync7, rmSync as rmSync11, writeSync as writeSync3 } from "node:fs";
25973
+ import { closeSync as closeSync5, constants, fsyncSync as fsyncSync3, openSync as openSync5, renameSync as renameSync8, rmSync as rmSync11, writeSync as writeSync3 } from "node:fs";
25976
25974
  function atomicWriteFileSync2(destPath, contents, mode = 384) {
25977
25975
  const tmp = `${destPath}.tmp-${process.pid}-${randomBytes3(4).toString("hex")}`;
25978
25976
  const buf = typeof contents === "string" ? Buffer.from(contents, "utf-8") : contents;
@@ -25983,7 +25981,7 @@ function atomicWriteFileSync2(destPath, contents, mode = 384) {
25983
25981
  fsyncSync3(fd);
25984
25982
  closeSync5(fd);
25985
25983
  fd = null;
25986
- renameSync7(tmp, destPath);
25984
+ renameSync8(tmp, destPath);
25987
25985
  } catch (err) {
25988
25986
  if (fd !== null) {
25989
25987
  try {
@@ -49440,8 +49438,8 @@ var {
49440
49438
  } = import__.default;
49441
49439
 
49442
49440
  // src/build-info.ts
49443
- var VERSION = "0.14.36";
49444
- var COMMIT_SHA = "127b6f28";
49441
+ var VERSION = "0.14.38";
49442
+ var COMMIT_SHA = "1529105b";
49445
49443
 
49446
49444
  // src/cli/agent.ts
49447
49445
  init_source();
@@ -49522,6 +49520,7 @@ import {
49522
49520
  writeFileSync as writeFileSync5,
49523
49521
  appendFileSync,
49524
49522
  readFileSync as readFileSync11,
49523
+ renameSync as renameSync3,
49525
49524
  chmodSync as chmodSync2,
49526
49525
  symlinkSync as symlinkSync2,
49527
49526
  copyFileSync as copyFileSync4,
@@ -51637,6 +51636,7 @@ This file is auto-maintained. Do not edit manually.
51637
51636
  `;
51638
51637
  }, created, skipped, 384);
51639
51638
  writeIfMissing(join8(agentDir, "telegram", "access.json"), () => buildAccessJson2(agentConfig, telegramConfig, topicId, userId), created, skipped, 384);
51639
+ reconcileConfiguredGroup(join8(agentDir, "telegram", "access.json"), agentConfig, telegramConfig);
51640
51640
  if (agentConfig.subagents) {
51641
51641
  const agentsDir2 = join8(agentDir, ".claude", "agents");
51642
51642
  mkdirSync9(agentsDir2, { recursive: true });
@@ -52760,6 +52760,36 @@ function rerenderWithFingerprint(filePath, contentFn, created, skipped, rewritte
52760
52760
  rewrittenWithBackup.push(filePath);
52761
52761
  created.push(filePath);
52762
52762
  }
52763
+ function resolveAgentForumChatId(agentConfig, telegramConfig) {
52764
+ const override = agentConfig.channels?.telegram?.chat_id;
52765
+ if (typeof override === "string" && override.length > 0)
52766
+ return override;
52767
+ return telegramConfig.forum_chat_id ?? "";
52768
+ }
52769
+ function reconcileConfiguredGroup(accessPath, agentConfig, telegramConfig) {
52770
+ if (!existsSync13(accessPath))
52771
+ return;
52772
+ const forumChatId = resolveAgentForumChatId(agentConfig, telegramConfig);
52773
+ const hasRealForumChat = forumChatId !== "" && forumChatId !== "0";
52774
+ if (agentConfig.dm_only || !hasRealForumChat)
52775
+ return;
52776
+ let access;
52777
+ try {
52778
+ access = JSON.parse(readFileSync11(accessPath, "utf-8"));
52779
+ } catch {
52780
+ return;
52781
+ }
52782
+ const groups = access.groups ??= {};
52783
+ if (groups[forumChatId] !== undefined)
52784
+ return;
52785
+ const allowFrom = Array.isArray(access.allowFrom) ? access.allowFrom : [];
52786
+ groups[forumChatId] = { requireMention: false, allowFrom };
52787
+ const tmp = accessPath + ".tmp";
52788
+ writeFileSync5(tmp, JSON.stringify(access, null, 2) + `
52789
+ `, { mode: 384 });
52790
+ renameSync3(tmp, accessPath);
52791
+ console.log(source_default.green(` registered supergroup ${forumChatId} in access.json ` + `(responds to all topics; requireMention=false)`));
52792
+ }
52763
52793
  function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId) {
52764
52794
  const allowFrom = userId ? [String(userId)] : [];
52765
52795
  if (allowFrom.length === 0) {
@@ -52769,7 +52799,7 @@ function buildAccessJson2(agentConfig, telegramConfig, resolvedTopicId, userId)
52769
52799
  dmPolicy: "allowlist",
52770
52800
  allowFrom
52771
52801
  };
52772
- const forumChatId = telegramConfig.forum_chat_id;
52802
+ const forumChatId = resolveAgentForumChatId(agentConfig, telegramConfig);
52773
52803
  const hasRealForumChat = forumChatId !== "" && forumChatId !== "0";
52774
52804
  if (!agentConfig.dm_only && hasRealForumChat) {
52775
52805
  access.groups = {
@@ -53035,7 +53065,7 @@ import { join as join12 } from "node:path";
53035
53065
  import { execFileSync as execFileSync8 } from "node:child_process";
53036
53066
 
53037
53067
  // src/agents/handoff-summarizer.ts
53038
- import { readFileSync as readFileSync14, writeFileSync as writeFileSync8, renameSync as renameSync4, mkdirSync as mkdirSync13, existsSync as existsSync16, statSync as statSync9, readdirSync as readdirSync8 } from "node:fs";
53068
+ import { readFileSync as readFileSync14, writeFileSync as writeFileSync8, renameSync as renameSync5, mkdirSync as mkdirSync13, existsSync as existsSync16, statSync as statSync9, readdirSync as readdirSync8 } from "node:fs";
53039
53069
  import { join as join11 } from "node:path";
53040
53070
  var DEFAULT_MAX_TURNS = 50;
53041
53071
  var TOPIC_MAX_CHARS = 117;
@@ -53177,8 +53207,8 @@ function writeSidecarsAtomic(agentDir, briefing, topic) {
53177
53207
  const topicTmp = topicPath + ".tmp";
53178
53208
  writeFileSync8(handoffTmp, briefing, "utf-8");
53179
53209
  writeFileSync8(topicTmp, topic, "utf-8");
53180
- renameSync4(handoffTmp, handoffPath);
53181
- renameSync4(topicTmp, topicPath);
53210
+ renameSync5(handoffTmp, handoffPath);
53211
+ renameSync5(topicTmp, topicPath);
53182
53212
  }
53183
53213
  async function buildHandoff(opts) {
53184
53214
  const maxTurns = opts.maxTurns ?? DEFAULT_MAX_TURNS;
@@ -59057,7 +59087,7 @@ import { spawn as spawn4 } from "node:child_process";
59057
59087
  init_compose();
59058
59088
  init_vault();
59059
59089
  import * as net3 from "node:net";
59060
- import { mkdirSync as mkdirSync22, chmodSync as chmodSync7, chownSync as chownSync2, existsSync as existsSync35, readFileSync as readFileSync30, readdirSync as readdirSync16, statSync as statSync19, unlinkSync as unlinkSync8, writeFileSync as writeFileSync19, renameSync as renameSync9 } from "node:fs";
59090
+ import { mkdirSync as mkdirSync22, chmodSync as chmodSync7, chownSync as chownSync2, existsSync as existsSync35, readFileSync as readFileSync30, readdirSync as readdirSync16, statSync as statSync19, unlinkSync as unlinkSync8, writeFileSync as writeFileSync19, renameSync as renameSync10 } from "node:fs";
59061
59091
  import { dirname as dirname6, resolve as resolve26, basename as basename4 } from "node:path";
59062
59092
  import * as os4 from "node:os";
59063
59093
  import * as path3 from "node:path";
@@ -59074,7 +59104,7 @@ import {
59074
59104
  openSync as openSync6,
59075
59105
  closeSync as closeSync6,
59076
59106
  readFileSync as readFileSync28,
59077
- renameSync as renameSync8,
59107
+ renameSync as renameSync9,
59078
59108
  statSync as statSync18,
59079
59109
  symlinkSync as symlinkSync3,
59080
59110
  unlinkSync as unlinkSync7
@@ -59151,7 +59181,7 @@ function runMigration(home2, opts) {
59151
59181
  copyFileSync7(oldPath, tempNew);
59152
59182
  chmodSync4(tempNew, 384);
59153
59183
  fsyncFile(tempNew);
59154
- renameSync8(tempNew, newPath);
59184
+ renameSync9(tempNew, newPath);
59155
59185
  fsyncDir(parent);
59156
59186
  atomicReplaceWithSymlink(oldPath, "vault/vault.enc");
59157
59187
  fsyncDir(switchroomRoot);
@@ -59240,7 +59270,7 @@ function atomicReplaceWithSymlink(target, linkTarget) {
59240
59270
  } catch {}
59241
59271
  }
59242
59272
  symlinkSync3(linkTarget, tmp);
59243
- renameSync8(tmp, target);
59273
+ renameSync9(tmp, target);
59244
59274
  }
59245
59275
  function fsyncFile(path) {
59246
59276
  const fd = openSync6(path, "r+");
@@ -62880,7 +62910,7 @@ class VaultBroker {
62880
62910
  const tokenPath = path3.join(tokenDir, ".vault-token");
62881
62911
  const tmpPath = `${tokenPath}.tmp.${process.pid}`;
62882
62912
  writeFileSync19(tmpPath, mintResult.token, { mode: 384 });
62883
- renameSync9(tmpPath, tokenPath);
62913
+ renameSync10(tmpPath, tokenPath);
62884
62914
  try {
62885
62915
  const uid = allocateAgentUid(agent);
62886
62916
  chownSync2(tokenPath, uid, uid);
@@ -64262,7 +64292,7 @@ import {
64262
64292
  openSync as openSync9,
64263
64293
  readdirSync as readdirSync17,
64264
64294
  readFileSync as readFileSync34,
64265
- renameSync as renameSync10,
64295
+ renameSync as renameSync11,
64266
64296
  statSync as statSync20,
64267
64297
  symlinkSync as symlinkSync4,
64268
64298
  unlinkSync as unlinkSync10,
@@ -64414,7 +64444,7 @@ function backupVault(opts) {
64414
64444
  } catch {}
64415
64445
  throw new Error(`vault backup refused: '${fullPath}' already exists ` + `(sub-second collision with another backup). Retry in 1 second, ` + `or check for a concurrent backup process.`);
64416
64446
  }
64417
- renameSync10(tmpPath, fullPath);
64447
+ renameSync11(tmpPath, fullPath);
64418
64448
  const stat = statSync20(fullPath);
64419
64449
  const sha256 = sha256OfFile(fullPath);
64420
64450
  const row = {
@@ -74106,7 +74136,7 @@ import {
74106
74136
  openSync as openSync11,
74107
74137
  readdirSync as readdirSync21,
74108
74138
  readFileSync as readFileSync50,
74109
- renameSync as renameSync11,
74139
+ renameSync as renameSync12,
74110
74140
  statSync as statSync25,
74111
74141
  unlinkSync as unlinkSync11,
74112
74142
  writeFileSync as writeFileSync26,
@@ -74664,7 +74694,7 @@ function writeAll(stateDir, events) {
74664
74694
  `) + `
74665
74695
  `;
74666
74696
  writeFileSync26(tmp, body, "utf-8");
74667
- renameSync11(tmp, path4);
74697
+ renameSync12(tmp, path4);
74668
74698
  }
74669
74699
  var ORPHAN_TMP_TTL_MS = 60000;
74670
74700
  var TMP_PREFIX = `${ISSUES_FILE}.tmp-`;
@@ -76504,7 +76534,7 @@ import {
76504
76534
  readdirSync as readdirSync23,
76505
76535
  unlinkSync as unlinkSync12,
76506
76536
  existsSync as existsSync64,
76507
- renameSync as renameSync12
76537
+ renameSync as renameSync13
76508
76538
  } from "node:fs";
76509
76539
  import { join as join63, resolve as resolve41 } from "node:path";
76510
76540
  import { homedir as homedir37 } from "node:os";
@@ -76523,7 +76553,7 @@ function writeRecord(record2) {
76523
76553
  const tmp = `${target}.tmp${process.pid}`;
76524
76554
  writeFileSync30(tmp, JSON.stringify(record2, null, 2) + `
76525
76555
  `, { mode: 384 });
76526
- renameSync12(tmp, target);
76556
+ renameSync13(tmp, target);
76527
76557
  }
76528
76558
  function readRecord(id) {
76529
76559
  const path7 = recordPath(id);
@@ -77842,7 +77872,7 @@ async function fetchToken(vaultKey) {
77842
77872
 
77843
77873
  // src/cli/apply.ts
77844
77874
  init_source();
77845
- import { accessSync as accessSync3, chownSync as chownSync4, constants as fsConstants6, copyFileSync as copyFileSync11, existsSync as existsSync72, mkdirSync as mkdirSync40, readFileSync as readFileSync57, readdirSync as readdirSync26, renameSync as renameSync13, writeFileSync as writeFileSync35 } from "node:fs";
77875
+ import { accessSync as accessSync3, chownSync as chownSync4, constants as fsConstants6, copyFileSync as copyFileSync11, existsSync as existsSync72, mkdirSync as mkdirSync40, readFileSync as readFileSync57, readdirSync as readdirSync26, renameSync as renameSync14, writeFileSync as writeFileSync35 } from "node:fs";
77846
77876
  import { mkdir, writeFile } from "node:fs/promises";
77847
77877
  import { spawnSync as childSpawnSync } from "node:child_process";
77848
77878
  import readline from "node:readline";
@@ -78692,7 +78722,7 @@ function writeInstallTypeCache(homeDir = homedir40()) {
78692
78722
  source_paths: ctx.source_paths
78693
78723
  };
78694
78724
  writeFileSync35(tmp, JSON.stringify(payload, null, 2), { mode: 420 });
78695
- renameSync13(tmp, out);
78725
+ renameSync14(tmp, out);
78696
78726
  return out;
78697
78727
  }
78698
78728
  async function runApply(config, options, deps = {}, switchroomConfigPath) {
@@ -79764,7 +79794,7 @@ import {
79764
79794
  openSync as openSync13,
79765
79795
  readdirSync as readdirSync28,
79766
79796
  readFileSync as readFileSync60,
79767
- renameSync as renameSync14,
79797
+ renameSync as renameSync15,
79768
79798
  statSync as statSync28,
79769
79799
  unlinkSync as unlinkSync14,
79770
79800
  writeSync as writeSync8
@@ -79846,7 +79876,7 @@ function writeOverlayEntry(agent, slug, yamlText, opts = {}) {
79846
79876
  } finally {
79847
79877
  closeSync13(fd);
79848
79878
  }
79849
- renameSync14(stagingPath, finalPath);
79879
+ renameSync15(stagingPath, finalPath);
79850
79880
  return finalPath;
79851
79881
  });
79852
79882
  }
@@ -79863,7 +79893,7 @@ function writeSkillsOverlayEntry(agent, slug, yamlText, opts = {}) {
79863
79893
  } finally {
79864
79894
  closeSync13(fd);
79865
79895
  }
79866
- renameSync14(stagingPath, finalPath);
79896
+ renameSync15(stagingPath, finalPath);
79867
79897
  return finalPath;
79868
79898
  });
79869
79899
  }
@@ -80063,7 +80093,7 @@ import {
80063
80093
  openSync as openSync14,
80064
80094
  readdirSync as readdirSync29,
80065
80095
  readFileSync as readFileSync61,
80066
- renameSync as renameSync15,
80096
+ renameSync as renameSync16,
80067
80097
  unlinkSync as unlinkSync15,
80068
80098
  writeFileSync as writeFileSync36,
80069
80099
  writeSync as writeSync9
@@ -80106,7 +80136,7 @@ function stagePendingScheduleEntry(opts) {
80106
80136
  } finally {
80107
80137
  closeSync14(fd);
80108
80138
  }
80109
- renameSync15(yamlTmp, yamlPath);
80139
+ renameSync16(yamlTmp, yamlPath);
80110
80140
  }
80111
80141
  writeFileSync36(metaPath, JSON.stringify(meta, null, 2) + `
80112
80142
  `, { mode: 384 });
@@ -80145,7 +80175,7 @@ function commitPendingScheduleEntry(opts) {
80145
80175
  if (existsSync76(finalPath)) {
80146
80176
  return { committed: false, reason: "slug_collision" };
80147
80177
  }
80148
- renameSync15(match.yamlPath, finalPath);
80178
+ renameSync16(match.yamlPath, finalPath);
80149
80179
  unlinkSync15(match.metaPath);
80150
80180
  return { committed: true, path: finalPath, slug };
80151
80181
  }
@@ -80895,7 +80925,7 @@ import {
80895
80925
  readFileSync as readFileSync63,
80896
80926
  readdirSync as readdirSync30,
80897
80927
  realpathSync as realpathSync7,
80898
- renameSync as renameSync16,
80928
+ renameSync as renameSync17,
80899
80929
  rmSync as rmSync16,
80900
80930
  statSync as statSync29,
80901
80931
  writeFileSync as writeFileSync37
@@ -81350,9 +81380,9 @@ function writePayload(poolDir, name, files) {
81350
81380
  } catch {}
81351
81381
  if (targetExists) {
81352
81382
  oldRename = `${target}.skill-apply-old-${Date.now()}`;
81353
- renameSync16(target, oldRename);
81383
+ renameSync17(target, oldRename);
81354
81384
  }
81355
- renameSync16(staging, target);
81385
+ renameSync17(staging, target);
81356
81386
  if (oldRename) {
81357
81387
  rmSync16(oldRename, { recursive: true, force: true });
81358
81388
  oldRename = null;
@@ -81366,7 +81396,7 @@ function writePayload(poolDir, name, files) {
81366
81396
  if (existsSync79(target)) {
81367
81397
  rmSync16(target, { recursive: true, force: true });
81368
81398
  }
81369
- renameSync16(oldRename, target);
81399
+ renameSync17(oldRename, target);
81370
81400
  } catch {}
81371
81401
  }
81372
81402
  throw err2;
@@ -81446,7 +81476,7 @@ import {
81446
81476
  openSync as openSync16,
81447
81477
  readFileSync as readFileSync64,
81448
81478
  readdirSync as readdirSync31,
81449
- renameSync as renameSync17,
81479
+ renameSync as renameSync18,
81450
81480
  rmSync as rmSync17,
81451
81481
  statSync as statSync30,
81452
81482
  utimesSync,
@@ -81509,7 +81539,7 @@ function mirrorToConfigRepo(agent, name, liveSkillDir) {
81509
81539
  sweepMirrorPriors(configSkillsRoot);
81510
81540
  if (existsSync80(dest)) {
81511
81541
  const trash = join77(configSkillsRoot, `.${name}-trash-${Date.now()}`);
81512
- renameSync17(dest, trash);
81542
+ renameSync18(dest, trash);
81513
81543
  }
81514
81544
  return;
81515
81545
  }
@@ -81533,9 +81563,9 @@ function mirrorToConfigRepo(agent, name, liveSkillDir) {
81533
81563
  walk2(liveSkillDir, staging);
81534
81564
  if (existsSync80(dest)) {
81535
81565
  const prior = join77(configSkillsRoot, `.${name}-prior-${Date.now()}`);
81536
- renameSync17(dest, prior);
81566
+ renameSync18(dest, prior);
81537
81567
  }
81538
- renameSync17(staging, dest);
81568
+ renameSync18(staging, dest);
81539
81569
  } catch (err2) {
81540
81570
  process.stderr.write(source_default.yellow(`warning: mirror to ${dest} failed (${err2.message ?? err2}); ` + `live copy still works, but this skill is not version-controlled until next successful sync.
81541
81571
  `));
@@ -81718,9 +81748,9 @@ function writePersonalSkill(targetDir, files) {
81718
81748
  } catch {}
81719
81749
  if (targetExists) {
81720
81750
  oldRename = `${targetDir}.personal-old-${Date.now()}`;
81721
- renameSync17(targetDir, oldRename);
81751
+ renameSync18(targetDir, oldRename);
81722
81752
  }
81723
- renameSync17(staging, targetDir);
81753
+ renameSync18(staging, targetDir);
81724
81754
  if (oldRename) {
81725
81755
  rmSync17(oldRename, { recursive: true, force: true });
81726
81756
  oldRename = null;
@@ -81734,7 +81764,7 @@ function writePersonalSkill(targetDir, files) {
81734
81764
  if (existsSync80(targetDir)) {
81735
81765
  rmSync17(targetDir, { recursive: true, force: true });
81736
81766
  }
81737
- renameSync17(oldRename, targetDir);
81767
+ renameSync18(oldRename, targetDir);
81738
81768
  } catch {}
81739
81769
  }
81740
81770
  throw err2;
@@ -81972,7 +82002,7 @@ function removePersonalAction(name, opts) {
81972
82002
  mkdirSync45(trashRoot, { recursive: true, mode: 493 });
81973
82003
  const ts = Date.now();
81974
82004
  const trashTarget = join77(trashRoot, `${name}-${ts}`);
81975
- renameSync17(target, trashTarget);
82005
+ renameSync18(target, trashTarget);
81976
82006
  const now = new Date(ts);
81977
82007
  utimesSync(trashTarget, now, now);
81978
82008
  mirrorToConfigRepo(agent, name, null);
@@ -13836,18 +13836,11 @@ var TelegramChannelSchema = exports_external.object({
13836
13836
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
13837
13837
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
13838
13838
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
13839
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
13839
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
13840
13840
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
13841
13841
  }).optional().superRefine((tg, ctx) => {
13842
13842
  if (!tg)
13843
13843
  return;
13844
- if (tg.chat_id != null && tg.default_topic_id == null) {
13845
- ctx.addIssue({
13846
- code: exports_external.ZodIssueCode.custom,
13847
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
13848
- path: ["default_topic_id"]
13849
- });
13850
- }
13851
13844
  if (tg.default_topic_id != null && tg.chat_id == null) {
13852
13845
  ctx.addIssue({
13853
13846
  code: exports_external.ZodIssueCode.custom,
@@ -13862,6 +13855,11 @@ var TelegramChannelSchema = exports_external.object({
13862
13855
  path: ["topic_aliases"]
13863
13856
  });
13864
13857
  }
13858
+ }).transform((tg) => {
13859
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
13860
+ return { ...tg, default_topic_id: 1 };
13861
+ }
13862
+ return tg;
13865
13863
  });
13866
13864
  var ChannelsSchema = exports_external.object({
13867
13865
  telegram: TelegramChannelSchema
@@ -11422,18 +11422,11 @@ var init_schema = __esm(() => {
11422
11422
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11423
11423
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11424
11424
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11425
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11425
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11426
11426
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
11427
11427
  }).optional().superRefine((tg, ctx) => {
11428
11428
  if (!tg)
11429
11429
  return;
11430
- if (tg.chat_id != null && tg.default_topic_id == null) {
11431
- ctx.addIssue({
11432
- code: exports_external.ZodIssueCode.custom,
11433
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
11434
- path: ["default_topic_id"]
11435
- });
11436
- }
11437
11430
  if (tg.default_topic_id != null && tg.chat_id == null) {
11438
11431
  ctx.addIssue({
11439
11432
  code: exports_external.ZodIssueCode.custom,
@@ -11448,6 +11441,11 @@ var init_schema = __esm(() => {
11448
11441
  path: ["topic_aliases"]
11449
11442
  });
11450
11443
  }
11444
+ }).transform((tg) => {
11445
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11446
+ return { ...tg, default_topic_id: 1 };
11447
+ }
11448
+ return tg;
11451
11449
  });
11452
11450
  ChannelsSchema = exports_external.object({
11453
11451
  telegram: TelegramChannelSchema
@@ -11422,18 +11422,11 @@ var init_schema = __esm(() => {
11422
11422
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
11423
11423
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge — the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
11424
11424
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID — overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
11425
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11425
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted — set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field — the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
11426
11426
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot → alerts, hostd → admin, etc.). " + "Cascades per-key through defaults → profile → agent.")
11427
11427
  }).optional().superRefine((tg, ctx) => {
11428
11428
  if (!tg)
11429
11429
  return;
11430
- if (tg.chat_id != null && tg.default_topic_id == null) {
11431
- ctx.addIssue({
11432
- code: exports_external.ZodIssueCode.custom,
11433
- message: "`channels.telegram.chat_id` requires `default_topic_id` — supergroup-mode agents need a fallback topic for unclassified outbounds.",
11434
- path: ["default_topic_id"]
11435
- });
11436
- }
11437
11430
  if (tg.default_topic_id != null && tg.chat_id == null) {
11438
11431
  ctx.addIssue({
11439
11432
  code: exports_external.ZodIssueCode.custom,
@@ -11448,6 +11441,11 @@ var init_schema = __esm(() => {
11448
11441
  path: ["topic_aliases"]
11449
11442
  });
11450
11443
  }
11444
+ }).transform((tg) => {
11445
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
11446
+ return { ...tg, default_topic_id: 1 };
11447
+ }
11448
+ return tg;
11451
11449
  });
11452
11450
  ChannelsSchema = exports_external.object({
11453
11451
  telegram: TelegramChannelSchema
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.36",
3
+ "version": "0.14.38",
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": {
@@ -23807,18 +23807,11 @@ var init_schema = __esm(() => {
23807
23807
  webhook_via_gateway: exports_external.boolean().optional().describe("Route verified webhook events to the agent's in-container gateway " + "over a peercred-gated UDS (<agent>/telegram/webhook.sock) instead " + "of having the host-side web receiver write the agent dir directly. " + "Required under the Docker runtime: the receiver runs as the host " + "operator UID and cannot write the per-agent-UID-owned agent dir " + "(EACCES 500) nor connect the gateway socket. When true the gateway " + "(running as the agent UID) becomes the sole writer of " + "webhook-events.jsonl + dedup/cooldown state and also fires " + "webhook_dispatch. Off by default for back-compat with host-runtime " + "installs. See docs/rfcs/webhook-via-gateway-socket.md."),
23808
23808
  webhook_require_edge: exports_external.boolean().optional().describe("Cloudflare-only edge lock: require the X-Switchroom-Edge header " + "(injected by a Cloudflare Transform Rule on hooks.switchroom.ai) to " + "match the operator's edge secret at ~/.switchroom/webhook-edge-secret " + "before any HMAC verification; reject 403 otherwise. Proves the " + "request entered through our Cloudflare edge \u2014 the per-agent HMAC " + "alone can't (it proves body provenance, not network path). Stacks " + "on the GitHub-IP WAF + per-agent HMAC. Fail-closed: when required " + "but the secret file is missing/empty every request is rejected. Off " + "by default. See docs/rfcs/webhook-cloudflare-edge-lock.md."),
23809
23809
  chat_id: exports_external.string().regex(/^-\d+$/, 'supergroup chat_id must be a negative integer as a string (e.g. "-1001234567890")').optional().describe("Per-agent supergroup ID \u2014 overrides fleet `telegram.forum_chat_id`. " + "When set, requires `default_topic_id`. Negative integer as string. " + "Forbidden when `dm_only: true`. See docs/rfcs/supergroup-mode.md."),
23810
- default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Required when `chat_id` is set. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
23810
+ default_topic_id: exports_external.number().int().positive().optional().describe("Forum topic ID this agent's automated outbounds default to when " + "no more-specific alias resolves. Defaults to General (topic 1) when " + "`chat_id` is set and this is omitted \u2014 set it only to pin a different " + "fallback topic. " + "Telegram's General topic is `id=1` at MTProto but sends omit the " + "field \u2014 the outbound wrapper strips `message_thread_id === 1` " + "on send. Forbidden when `dm_only: true`."),
23811
23811
  topic_aliases: exports_external.record(exports_external.string(), exports_external.number().int().positive()).optional().describe("Operator-friendly names for forum topic IDs (e.g. " + "`{ general: 1, planning: 17, cron: 23, admin: 31, alerts: 41 }`). " + "Referenced from per-cron `topic:` fields and the outbound router " + "for autonomous events (boot \u2192 alerts, hostd \u2192 admin, etc.). " + "Cascades per-key through defaults \u2192 profile \u2192 agent.")
23812
23812
  }).optional().superRefine((tg, ctx) => {
23813
23813
  if (!tg)
23814
23814
  return;
23815
- if (tg.chat_id != null && tg.default_topic_id == null) {
23816
- ctx.addIssue({
23817
- code: exports_external.ZodIssueCode.custom,
23818
- message: "`channels.telegram.chat_id` requires `default_topic_id` \u2014 supergroup-mode agents need a fallback topic for unclassified outbounds.",
23819
- path: ["default_topic_id"]
23820
- });
23821
- }
23822
23815
  if (tg.default_topic_id != null && tg.chat_id == null) {
23823
23816
  ctx.addIssue({
23824
23817
  code: exports_external.ZodIssueCode.custom,
@@ -23833,6 +23826,11 @@ var init_schema = __esm(() => {
23833
23826
  path: ["topic_aliases"]
23834
23827
  });
23835
23828
  }
23829
+ }).transform((tg) => {
23830
+ if (tg && tg.chat_id != null && tg.default_topic_id == null) {
23831
+ return { ...tg, default_topic_id: 1 };
23832
+ }
23833
+ return tg;
23836
23834
  });
23837
23835
  ChannelsSchema = exports_external.object({
23838
23836
  telegram: TelegramChannelSchema
@@ -49571,6 +49569,20 @@ function backfillJsonlAgentId(db2, jsonlPath, agentId, log) {
49571
49569
  }
49572
49570
  db2.prepare("UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?").run(agentId, candidate.id);
49573
49571
  log?.(`subagent-watcher: backfill linked ${agentId} \u2192 ${candidate.id}`);
49572
+ try {
49573
+ const linkedRow = db2.prepare("SELECT started_at, parent_turn_key FROM subagents WHERE id = ?").get(candidate.id);
49574
+ if (linkedRow != null && linkedRow.parent_turn_key == null) {
49575
+ const turn = db2.prepare(`SELECT turn_key FROM turns
49576
+ WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
49577
+ ORDER BY started_at DESC LIMIT 1`).get(linkedRow.started_at, linkedRow.started_at);
49578
+ if (turn?.turn_key != null) {
49579
+ db2.prepare("UPDATE subagents SET parent_turn_key = ? WHERE id = ?").run(turn.turn_key, candidate.id);
49580
+ log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} \u2192 ${turn.turn_key}`);
49581
+ }
49582
+ }
49583
+ } catch (err) {
49584
+ log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} \u2014 ${err.message}`);
49585
+ }
49574
49586
  }
49575
49587
  function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, parentStateDir, onUnstall, onFileVanished, onProgress) {
49576
49588
  try {
@@ -51782,10 +51794,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51782
51794
  }
51783
51795
 
51784
51796
  // ../src/build-info.ts
51785
- var VERSION = "0.14.36";
51786
- var COMMIT_SHA = "127b6f28";
51787
- var COMMIT_DATE = "2026-06-01T23:38:45Z";
51788
- var LATEST_PR = 2074;
51797
+ var VERSION = "0.14.38";
51798
+ var COMMIT_SHA = "1529105b";
51799
+ var COMMIT_DATE = "2026-06-02T02:25:47Z";
51800
+ var LATEST_PR = 2079;
51789
51801
  var COMMITS_AHEAD_OF_TAG = 0;
51790
51802
 
51791
51803
  // gateway/boot-version.ts
@@ -156,6 +156,53 @@ function extractResultSummary(toolResponse) {
156
156
  return str.slice(0, 200) || null
157
157
  }
158
158
 
159
+ /**
160
+ * Extract the full text of a PostToolUse tool_response (untruncated).
161
+ * Mirrors extractResultSummary's shape handling but returns the whole
162
+ * string so callers can pattern-match on it.
163
+ */
164
+ function toolResponseText(toolResponse) {
165
+ if (!toolResponse) return ''
166
+ if (Array.isArray(toolResponse.content)) {
167
+ return toolResponse.content
168
+ .filter((c) => c && typeof c === 'object' && c.type === 'text' && typeof c.text === 'string')
169
+ .map((c) => c.text)
170
+ .join('\n')
171
+ }
172
+ if (typeof toolResponse.result === 'string') return toolResponse.result
173
+ if (typeof toolResponse.output === 'string') return toolResponse.output
174
+ if (typeof toolResponse === 'string') return toolResponse
175
+ return ''
176
+ }
177
+
178
+ /**
179
+ * Detect Claude Code's async-launch ACK in a PostToolUse tool_response.
180
+ *
181
+ * A `run_in_background` Agent/Task returns IMMEDIATELY with an
182
+ * acknowledgement ("Async agent launched successfully … The agent is working
183
+ * in the background …"), NOT the sub-agent's final result. This ACK is the
184
+ * authoritative, uniform signal that the dispatch was a background one — it is
185
+ * present even when Claude Code omits `run_in_background` from the tool_input
186
+ * the PREtool hook sees (observed on claude-code 2.1.159: a worker whose
187
+ * tool_input lacked the flag still returned this ACK and ran ~3 min past the
188
+ * parent turn, so the pretool recorded background=0 and the worker card never
189
+ * fired). We therefore trust this ACK over the pretool's input-derived flag.
190
+ *
191
+ * Anchored on the specific "async agent launched" phrase (a foreground
192
+ * sub-agent's final report is extremely unlikely to contain it), with a
193
+ * structural backstop ("working in the background" + an agentId token) in case
194
+ * the launch-verb wording drifts. A major wording change degrades to the
195
+ * pretool flag — still correct whenever the model DID pass run_in_background,
196
+ * never worse than before.
197
+ */
198
+ function isAsyncLaunchAck(toolResponse) {
199
+ const t = toolResponseText(toolResponse).toLowerCase()
200
+ if (!t) return false
201
+ if (t.includes('async agent launched')) return true
202
+ if (t.includes('working in the background') && t.includes('agentid')) return true
203
+ return false
204
+ }
205
+
159
206
  // ---------------------------------------------------------------------------
160
207
  // DB write
161
208
  // ---------------------------------------------------------------------------
@@ -172,9 +219,18 @@ function extractResultSummary(toolResponse) {
172
219
  * recordSubagentEnd (driven by the JSONL turn_end event) remains the
173
220
  * authoritative end-of-life signal.
174
221
  *
222
+ * Mis-recorded background (DB background = 0 but `asyncLaunch` is true):
223
+ * Claude Code returned the async-launch ACK even though run_in_background was
224
+ * absent from the tool_input the pretool saw, so the row was wrongly recorded
225
+ * foreground. PROMOTE it to background = 1 and take the background path — do
226
+ * NOT terminalize, because the worker is still running (the ACK is a launch,
227
+ * not a completion). This is the authoritative correction that makes the
228
+ * gateway's worker-feed card fire (onProgress re-reads `background` per tick)
229
+ * AND prevents the premature `completed` the foreground path would write.
230
+ *
175
231
  * The done(err | null) callback is invoked after all DB operations complete.
176
232
  */
177
- function updateRow(dbPath, { id, status, resultSummary, now }, done) {
233
+ function updateRow(dbPath, { id, status, resultSummary, now, asyncLaunch }, done) {
178
234
  // SQL to read the background flag so we can choose the right update path.
179
235
  const SELECT_SQL = `SELECT background FROM subagents WHERE id = ?`
180
236
 
@@ -194,12 +250,22 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
194
250
  AND status NOT IN ('completed', 'failed')
195
251
  `
196
252
 
253
+ // Promote a mis-recorded foreground row to background (sets background = 1),
254
+ // bumping activity but NOT terminalizing — same shape as BACKGROUND_SQL.
255
+ const PROMOTE_BACKGROUND_SQL = `
256
+ UPDATE subagents
257
+ SET background = 1, result_summary = COALESCE(?, result_summary), last_activity_at = ?
258
+ WHERE id = ?
259
+ AND status NOT IN ('completed', 'failed')
260
+ `
261
+
197
262
  // Snapshot all values used inside closures before setImmediate fires.
198
263
  const snapDbPath = dbPath
199
264
  const snapId = id
200
265
  const snapStatus = status
201
266
  const snapResultSummary = resultSummary
202
267
  const snapNow = now
268
+ const snapAsyncLaunch = asyncLaunch === true
203
269
 
204
270
  // Resolve a synchronous SQLite binding (node:sqlite under Node 22+,
205
271
  // bun:sqlite under bun, else null → CLI fallback). See helper docs.
@@ -216,6 +282,8 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
216
282
  const isBackground = row != null && row.background === 1
217
283
  if (isBackground) {
218
284
  db.prepare(BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
285
+ } else if (snapAsyncLaunch) {
286
+ db.prepare(PROMOTE_BACKGROUND_SQL).run(snapResultSummary, snapNow, snapId)
219
287
  } else {
220
288
  db.prepare(FOREGROUND_SQL).run(snapNow, snapStatus, snapResultSummary, snapNow, snapId)
221
289
  }
@@ -239,6 +307,12 @@ function updateRow(dbPath, { id, status, resultSummary, now }, done) {
239
307
  fillPlaceholders(BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
240
308
  done,
241
309
  )
310
+ } else if (snapAsyncLaunch) {
311
+ spawnSql(
312
+ snapDbPath,
313
+ fillPlaceholders(PROMOTE_BACKGROUND_SQL.trim(), [snapResultSummary, snapNow, snapId]),
314
+ done,
315
+ )
242
316
  } else {
243
317
  spawnSql(
244
318
  snapDbPath,
@@ -345,16 +419,26 @@ function main() {
345
419
 
346
420
  const toolResponse = event.tool_response ?? null
347
421
 
422
+ // Authoritative background signal: Claude Code's async-launch ACK. Trusted
423
+ // over the pretool's input-derived flag (which is missing whenever the
424
+ // model/runtime omits run_in_background from tool_input — see
425
+ // isAsyncLaunchAck). Gates both the nudge below and the promote path in
426
+ // updateRow.
427
+ const asyncLaunch = isAsyncLaunchAck(toolResponse)
428
+
348
429
  // conversational-pacing beat 4 (foreground half). A foreground
349
430
  // sub-agent's PostToolUse fires at real completion, mid-parent-turn,
350
431
  // with its result in tool_response — nudge the parent to synthesise a
351
432
  // user-facing handback. Background sub-agents are gated OUT: their
352
433
  // PostToolUse fires on the launch ACK (BACKGROUND_SQL leaves status
353
434
  // untouched for that reason), and their handback is driven by the
354
- // gateway's subagent-watcher onFinish path instead. Fail-silent: an
355
- // unknown background flag (null) skips the nudge.
435
+ // gateway's subagent-watcher onFinish path instead. A launch ACK is also
436
+ // gated out via `!asyncLaunch` at this point the DB flag may still read 0
437
+ // (updateRow promotes it on the next tick), so the ACK is the reliable
438
+ // tell. Fail-silent: an unknown background flag (null) skips the nudge.
356
439
  if (
357
440
  process.env.SWITCHROOM_SUBAGENT_HANDBACK !== '0'
441
+ && !asyncLaunch
358
442
  && detectStatus(toolResponse) === 'completed'
359
443
  && readBackgroundFlagSync(dbPath, id) === 0
360
444
  ) {
@@ -368,6 +452,7 @@ function main() {
368
452
  status: detectStatus(toolResponse),
369
453
  resultSummary: extractResultSummary(toolResponse),
370
454
  now: Date.now(),
455
+ asyncLaunch,
371
456
  },
372
457
  (err) => {
373
458
  if (err) {
@@ -262,7 +262,17 @@ function main() {
262
262
  {
263
263
  id: event.tool_use_id ?? null,
264
264
  parentSessionId: event.session_id ?? null,
265
- parentTurnKey: event.turn_id ?? null,
265
+ // parent_turn_key is intentionally NULL here. Claude Code's PreToolUse
266
+ // payload carries its own session id, not the gateway-minted Telegram
267
+ // turn_key (a chat+topic+turn key) the `turns` table is keyed on —
268
+ // `event.turn_id` is always undefined, and even if a future CLI
269
+ // populated it, it would not match a `turns.turn_key`. The gateway
270
+ // resolves parent_turn_key from the
271
+ // sub-agent's started_at at jsonl-link time (subagent-watcher.ts
272
+ // backfillJsonlAgentId), which works even after the parent turn ends.
273
+ // Writing a bogus value here would defeat that backfill's
274
+ // `parent_turn_key IS NULL` guard.
275
+ parentTurnKey: null,
266
276
  agentType: input.subagent_type ?? null,
267
277
  description: input.description ?? null,
268
278
  background: input.run_in_background === true ? 1 : 0,
@@ -387,8 +387,16 @@ describe('Bug 4 — result_summary always NULL (hook integration)', () => {
387
387
 
388
388
  // ─── Bug 5 — parent_turn_key always NULL ─────────────────────────────────────
389
389
 
390
- describe('Bug 5 — parent_turn_key always NULL (hook integration)', () => {
391
- it('pretool stores parent_turn_key from event.turn_id', () => {
390
+ describe('Bug 5 — parent_turn_key backfilled by gateway, not the hook', () => {
391
+ it('pretool writes parent_turn_key=NULL even when event.turn_id is present', () => {
392
+ // Claude Code's PreToolUse payload carries its own session id, never the
393
+ // gateway-minted Telegram turn_key (a chat+topic+turn key) the `turns`
394
+ // table is keyed on. `event.turn_id` — even if a future CLI populated it —
395
+ // would not match a `turns.turn_key`, so the hook intentionally writes
396
+ // NULL and lets the gateway backfill parent_turn_key from the sub-agent's
397
+ // started_at at jsonl-link time (subagent-watcher.ts backfillJsonlAgentId).
398
+ // Writing a bogus value here would defeat that backfill's
399
+ // `parent_turn_key IS NULL` guard.
392
400
  const event = {
393
401
  session_id: 'sess-turnkey',
394
402
  turn_id: 'turn-abc-001',
@@ -406,8 +414,8 @@ describe('Bug 5 — parent_turn_key always NULL (hook integration)', () => {
406
414
  | undefined
407
415
 
408
416
  expect(row).toBeDefined()
409
- // After fix: parent_turn_key should be populated from event.turn_id
410
- expect(row!.parent_turn_key).toBe('turn-abc-001')
417
+ // The hook never trusts event.turn_id gateway backfill owns this column.
418
+ expect(row!.parent_turn_key).toBeNull()
411
419
  })
412
420
 
413
421
  it('pretool stores parent_turn_key as NULL when turn_id absent (no regression)', () => {
@@ -508,7 +508,10 @@ interface FsLike {
508
508
  * - Row already linked to a different agentId: SQL `WHERE jsonl_agent_id IS
509
509
  * NULL` skips it. Re-runs are safe.
510
510
  */
511
- function backfillJsonlAgentId(
511
+ // Exported for unit-testing the parent_turn_key backfill (telegram-plugin/
512
+ // tests/subagent-watcher-parent-turn-key.test.ts). Not intended for
513
+ // consumption by other modules.
514
+ export function backfillJsonlAgentId(
512
515
  db: SubagentLivenessDb,
513
516
  jsonlPath: string,
514
517
  agentId: string,
@@ -555,6 +558,47 @@ function backfillJsonlAgentId(
555
558
  .prepare('UPDATE subagents SET jsonl_agent_id = ? WHERE id = ?')
556
559
  .run(agentId, candidate.id)
557
560
  log?.(`subagent-watcher: backfill linked ${agentId} → ${candidate.id}`)
561
+
562
+ // Backfill parent_turn_key (gateway-side). The PreToolUse hook can't know
563
+ // the gateway-minted Telegram turn_key (a chat+topic+turn key) — it only
564
+ // sees Claude Code's session id — so the row was inserted with
565
+ // parent_turn_key=NULL. Resolve
566
+ // it now from the turn whose [started_at, ended_at] window contained the
567
+ // sub-agent's dispatch (its started_at). Keying on the historical
568
+ // started_at, NOT "the turn active now", is what makes this correct for a
569
+ // background worker that outlives its parent turn: the turn may have already
570
+ // ended by link time, but the containment match still finds it. Turns are
571
+ // processed serially per agent, so at most one window contains a given
572
+ // instant; the ORDER BY ... DESC LIMIT 1 is just a defensive tie-break.
573
+ //
574
+ // Without this, resolveSubagentOriginChat() returns null and the live
575
+ // worker card + handback fall back to the operator DM instead of the
576
+ // originating group/forum-topic, and resolveCallingSubagent()'s turn-scoped
577
+ // heuristic (WHERE parent_turn_key = ?) can never see the row. Best-effort:
578
+ // any failure leaves parent_turn_key NULL (today's behaviour) and never
579
+ // throws out of the watcher poll loop.
580
+ try {
581
+ const linkedRow = db
582
+ .prepare('SELECT started_at, parent_turn_key FROM subagents WHERE id = ?')
583
+ .get(candidate.id) as { started_at: number; parent_turn_key: string | null } | null
584
+ if (linkedRow != null && linkedRow.parent_turn_key == null) {
585
+ const turn = db
586
+ .prepare(
587
+ `SELECT turn_key FROM turns
588
+ WHERE started_at <= ? AND (ended_at IS NULL OR ended_at >= ?)
589
+ ORDER BY started_at DESC LIMIT 1`,
590
+ )
591
+ .get(linkedRow.started_at, linkedRow.started_at) as { turn_key: string } | null
592
+ if (turn?.turn_key != null) {
593
+ db
594
+ .prepare('UPDATE subagents SET parent_turn_key = ? WHERE id = ?')
595
+ .run(turn.turn_key, candidate.id)
596
+ log?.(`subagent-watcher: backfill parent_turn_key ${candidate.id} → ${turn.turn_key}`)
597
+ }
598
+ }
599
+ } catch (err) {
600
+ log?.(`subagent-watcher: parent_turn_key backfill skipped for ${candidate.id} — ${(err as Error).message}`)
601
+ }
558
602
  }
559
603
 
560
604
  // Exported for unit-testing the ENOENT/EACCES deregister path
@@ -271,6 +271,79 @@ describe('subagent-tracker-posttool', () => {
271
271
  expect(postResult.status).toBe(0)
272
272
  expect(postResult.stdout).not.toContain('additionalContext')
273
273
  })
274
+
275
+ // The async-launch ACK is Claude Code's verbatim immediate return for a
276
+ // run_in_background Agent/Task dispatch. The posttool trusts it over the
277
+ // pretool's input-derived background flag, which is missing whenever the
278
+ // runtime omits run_in_background from the tool_input the pretool saw
279
+ // (observed on claude-code 2.1.159 — the clerk worker that never surfaced).
280
+ const ASYNC_LAUNCH_ACK =
281
+ 'Async agent launched successfully.\n'
282
+ + 'agentId: go-live-sync-a176dc93\n'
283
+ + 'The agent is working in the background. You will be notified '
284
+ + 'automatically when it completes.'
285
+
286
+ it('promotes a mis-recorded foreground row to background from the launch ACK', () => {
287
+ // Pretool sees NO run_in_background key (the production bug) → records
288
+ // background=0, status=running.
289
+ const preResult = runHook(PRETOOL_SCRIPT, {
290
+ session_id: 's-promote',
291
+ tool_name: 'Agent',
292
+ tool_use_id: 'toolu_promote1',
293
+ tool_input: { subagent_type: 'worker', description: 'Go-live sync' },
294
+ })
295
+ expect(preResult.status).toBe(0)
296
+
297
+ const db = openDb()
298
+ const before = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_promote1') as
299
+ | { background: number; status: string }
300
+ | undefined
301
+ expect(before?.background).toBe(0)
302
+ expect(before?.status).toBe('running')
303
+
304
+ // Posttool receives the async-launch ACK → promote to background, do NOT
305
+ // terminalize, and do NOT emit a foreground handback nudge.
306
+ const postResult = runHook(POSTTOOL_SCRIPT, {
307
+ tool_name: 'Agent',
308
+ tool_use_id: 'toolu_promote1',
309
+ tool_response: { content: [{ type: 'text', text: ASYNC_LAUNCH_ACK }] },
310
+ })
311
+ expect(postResult.status).toBe(0)
312
+ expect(postResult.stdout).not.toContain('additionalContext')
313
+
314
+ const after = db.prepare('SELECT background, status, ended_at FROM subagents WHERE id = ?').get('toolu_promote1') as
315
+ | { background: number; status: string; ended_at: number | null }
316
+ | undefined
317
+ expect(after?.background).toBe(1)
318
+ expect(after?.status).toBe('running')
319
+ expect(after?.ended_at == null).toBe(true)
320
+ })
321
+
322
+ it('still terminalizes a genuine foreground completion (no false promote)', () => {
323
+ // A real foreground sub-agent whose final report happens to mention
324
+ // "background" must NOT be mistaken for a launch ACK — the promote path
325
+ // only fires on the specific async-launch phrasing.
326
+ runHook(PRETOOL_SCRIPT, {
327
+ session_id: 's-noflip',
328
+ tool_name: 'Agent',
329
+ tool_use_id: 'toolu_noflip1',
330
+ tool_input: { subagent_type: 'worker', description: 'Real foreground task', run_in_background: false },
331
+ })
332
+ const postResult = runHook(POSTTOOL_SCRIPT, {
333
+ tool_name: 'Agent',
334
+ tool_use_id: 'toolu_noflip1',
335
+ tool_response: { result: 'Done. The feature now runs as a background job.', is_error: false },
336
+ })
337
+ expect(postResult.status).toBe(0)
338
+ expect(postResult.stdout).toContain('additionalContext')
339
+
340
+ const db = openDb()
341
+ const row = db.prepare('SELECT background, status FROM subagents WHERE id = ?').get('toolu_noflip1') as
342
+ | { background: number; status: string }
343
+ | undefined
344
+ expect(row?.background).toBe(0)
345
+ expect(row?.status).toBe('completed')
346
+ })
274
347
  })
275
348
 
276
349
  describe('agent-dir resolution (RFC §Bug 2)', () => {
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Unit tests for the parent_turn_key backfill in subagent-watcher.ts
3
+ * (backfillJsonlAgentId).
4
+ *
5
+ * The PreToolUse hook records a sub-agent row with parent_turn_key=NULL — it
6
+ * only sees Claude Code's session id, never the Telegram turn_key
7
+ * (chat_id:msg_id) the gateway keys turns on. The gateway backfills
8
+ * parent_turn_key when it links the JSONL stem to the row, resolving it from
9
+ * the turn whose [started_at, ended_at] window contained the sub-agent's
10
+ * dispatch (its started_at). These tests pin that resolution — in particular
11
+ * that it stays correct for a background worker that outlives its parent turn.
12
+ *
13
+ * bun:sqlite — run under Bun:
14
+ * bun test telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts
15
+ */
16
+
17
+ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
18
+ import { mkdtempSync, rmSync, writeFileSync } from 'fs'
19
+ import { tmpdir } from 'os'
20
+ import { join } from 'path'
21
+ import { openTurnsDbInMemory } from '../registry/turns-schema.js'
22
+ import { applySubagentsSchema } from '../registry/subagents-schema.js'
23
+ import { backfillJsonlAgentId } from '../subagent-watcher.js'
24
+
25
+ type Db = ReturnType<typeof openTurnsDbInMemory>
26
+
27
+ let tempDir: string
28
+ let db: Db
29
+
30
+ beforeEach(() => {
31
+ tempDir = mkdtempSync(join(tmpdir(), 'sub-parent-turn-'))
32
+ db = openTurnsDbInMemory()
33
+ applySubagentsSchema(db)
34
+ })
35
+
36
+ afterEach(() => {
37
+ try { db.close() } catch { /* ignore */ }
38
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch { /* ignore */ }
39
+ })
40
+
41
+ function insertTurn(args: {
42
+ turnKey: string
43
+ chatId: string
44
+ threadId?: string | null
45
+ startedAt: number
46
+ endedAt?: number | null
47
+ }) {
48
+ db.prepare(`
49
+ INSERT INTO turns (turn_key, chat_id, thread_id, started_at, ended_at, created_at, updated_at)
50
+ VALUES (?, ?, ?, ?, ?, ?, ?)
51
+ `).run(
52
+ args.turnKey,
53
+ args.chatId,
54
+ args.threadId ?? null,
55
+ args.startedAt,
56
+ args.endedAt ?? null,
57
+ args.startedAt,
58
+ args.startedAt,
59
+ )
60
+ }
61
+
62
+ function insertSub(args: {
63
+ id: string
64
+ agentType: string
65
+ description: string
66
+ startedAt: number
67
+ parentTurnKey?: string | null
68
+ }) {
69
+ db.prepare(`
70
+ INSERT INTO subagents
71
+ (id, parent_session_id, parent_turn_key, agent_type, description,
72
+ background, started_at, last_activity_at, status, jsonl_agent_id)
73
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', NULL)
74
+ `).run(
75
+ args.id,
76
+ 'sess-1',
77
+ args.parentTurnKey ?? null,
78
+ args.agentType,
79
+ args.description,
80
+ 1,
81
+ args.startedAt,
82
+ args.startedAt,
83
+ )
84
+ }
85
+
86
+ /** Write the meta.json the backfill reads to match a row by (agentType, description). */
87
+ function writeMeta(agentType: string, description: string): string {
88
+ const jsonlPath = join(tempDir, 'worker.jsonl')
89
+ writeFileSync(join(tempDir, 'worker.meta.json'), JSON.stringify({ agentType, description }))
90
+ return jsonlPath
91
+ }
92
+
93
+ function readSub(id: string) {
94
+ return db.prepare('SELECT jsonl_agent_id, parent_turn_key FROM subagents WHERE id = ?').get(id) as
95
+ | { jsonl_agent_id: string | null; parent_turn_key: string | null }
96
+ | undefined
97
+ }
98
+
99
+ describe('backfillJsonlAgentId — parent_turn_key resolution', () => {
100
+ it('resolves parent_turn_key from the turn whose window contains the sub-agent started_at', () => {
101
+ insertTurn({ turnKey: '555:10', chatId: '555', threadId: '42', startedAt: 1000, endedAt: 2000 })
102
+ insertSub({ id: 'toolu_a', agentType: 'worker', description: 'Go-live sync', startedAt: 1500 })
103
+
104
+ const jsonlPath = writeMeta('worker', 'Go-live sync')
105
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_a')
106
+
107
+ const row = readSub('toolu_a')
108
+ expect(row?.jsonl_agent_id).toBe('agentstem_a')
109
+ expect(row?.parent_turn_key).toBe('555:10')
110
+ })
111
+
112
+ it('resolves to the parent turn even after it has ended (background worker outlives the turn)', () => {
113
+ // Parent turn already ended at 1600; a later turn is active "now".
114
+ insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 1600 })
115
+ insertTurn({ turnKey: '555:20', chatId: '555', startedAt: 1700, endedAt: null })
116
+ // The worker was dispatched at 1500 — inside the FIRST (now-ended) turn.
117
+ insertSub({ id: 'toolu_b', agentType: 'worker', description: 'Long task', startedAt: 1500 })
118
+
119
+ const jsonlPath = writeMeta('worker', 'Long task')
120
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_b')
121
+
122
+ // Must pick the containing (ended) turn, NOT the turn active at link time.
123
+ expect(readSub('toolu_b')?.parent_turn_key).toBe('555:10')
124
+ })
125
+
126
+ it('does NOT overwrite an already-populated parent_turn_key', () => {
127
+ insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
128
+ insertSub({
129
+ id: 'toolu_c',
130
+ agentType: 'worker',
131
+ description: 'Preset',
132
+ startedAt: 1500,
133
+ parentTurnKey: '999:9',
134
+ })
135
+
136
+ const jsonlPath = writeMeta('worker', 'Preset')
137
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_c')
138
+
139
+ expect(readSub('toolu_c')?.parent_turn_key).toBe('999:9')
140
+ })
141
+
142
+ it('leaves parent_turn_key NULL when no turn window contains the dispatch', () => {
143
+ insertTurn({ turnKey: '555:10', chatId: '555', startedAt: 1000, endedAt: 2000 })
144
+ // Dispatched at 50 — before any turn started.
145
+ insertSub({ id: 'toolu_d', agentType: 'worker', description: 'Orphan', startedAt: 50 })
146
+
147
+ const jsonlPath = writeMeta('worker', 'Orphan')
148
+ backfillJsonlAgentId(db, jsonlPath, 'agentstem_d')
149
+
150
+ const row = readSub('toolu_d')
151
+ // Still linked, but no turn to attribute it to.
152
+ expect(row?.jsonl_agent_id).toBe('agentstem_d')
153
+ expect(row?.parent_turn_key == null).toBe(true)
154
+ })
155
+ })