switchroom 0.14.31 → 0.14.33

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.
@@ -110,6 +110,138 @@ async function runAutoaccept(opts) {
110
110
  return { fired, reason: "manual-stop" };
111
111
  }
112
112
 
113
+ // src/agents/autoaccept.ts
114
+ import { execFileSync as execFileSync2 } from "node:child_process";
115
+ var PROMPTS2 = [
116
+ {
117
+ name: "dev-channels-loading",
118
+ match: /Loading.{1,30}development.{1,30}channels/,
119
+ keys: ["Enter"]
120
+ },
121
+ {
122
+ name: "dev-channels-local",
123
+ match: /using this for local development/,
124
+ keys: ["Enter"]
125
+ },
126
+ {
127
+ name: "dev-channels",
128
+ match: /I.{0,5}accept.{0,80}development.{0,10}channels/,
129
+ keys: ["Down", "Enter"]
130
+ },
131
+ {
132
+ name: "mcp-trust",
133
+ match: /Use this and all future MCP servers/,
134
+ keys: ["Enter"]
135
+ },
136
+ {
137
+ name: "theme",
138
+ match: /Choose.{1,30}text.{1,30}style/,
139
+ keys: ["Enter"]
140
+ },
141
+ {
142
+ name: "provider",
143
+ match: /Anthropic.{1,80}Bedrock/,
144
+ keys: ["Enter"]
145
+ },
146
+ {
147
+ name: "enter-to-confirm",
148
+ match: /Enter.{1,30}confirm/,
149
+ keys: ["Enter"]
150
+ }
151
+ ];
152
+ function capturePane2(agentName) {
153
+ const socket = `switchroom-${agentName}`;
154
+ try {
155
+ const out = execFileSync2("tmux", ["-L", socket, "capture-pane", "-p", "-t", agentName], {
156
+ timeout: 3000,
157
+ stdio: ["ignore", "pipe", "pipe"],
158
+ maxBuffer: 4 * 1024 * 1024
159
+ });
160
+ return out.toString("utf8");
161
+ } catch (err) {
162
+ console.error(`[autoaccept] ${agentName}: capture-pane failed: ${err.message}`);
163
+ return "";
164
+ }
165
+ }
166
+ function sendKeys2(agentName, keys) {
167
+ const socket = `switchroom-${agentName}`;
168
+ try {
169
+ execFileSync2("tmux", ["-L", socket, "send-keys", "-t", agentName, ...keys], { timeout: 3000, stdio: ["ignore", "pipe", "pipe"] });
170
+ return true;
171
+ } catch (err) {
172
+ console.error(`[autoaccept] ${agentName}: send-keys ${keys.join(" ")} failed: ${err.message}`);
173
+ return false;
174
+ }
175
+ }
176
+
177
+ // src/agents/wedge-watchdog.ts
178
+ var WEDGE_FOOTER_SIGNATURE = /(?=[\s\S]*[Ee]sc(?:ape)?[^\n]*cancel)(?=[\s\S]*(?:to select|to navigate|\u2191\/\u2193))/;
179
+ var DEFAULT_POLL_MS2 = 5000;
180
+ var DEFAULT_STABILITY_THRESHOLD = 3;
181
+ var DEFAULT_COOLDOWN_MS = 60000;
182
+ function defaultSleep2(ms) {
183
+ return new Promise((r) => setTimeout(r, ms));
184
+ }
185
+ function stabilityKey(text) {
186
+ return text.split(`
187
+ `).map((l) => l.replace(/\s+$/, "")).join(`
188
+ `);
189
+ }
190
+ async function runWedgeWatchdog(opts) {
191
+ const pollIntervalMs = opts.pollIntervalMs ?? DEFAULT_POLL_MS2;
192
+ const stabilityThreshold = opts.stabilityThreshold ?? DEFAULT_STABILITY_THRESHOLD;
193
+ const cooldownMs = opts.cooldownMs ?? DEFAULT_COOLDOWN_MS;
194
+ const deferToPrompts = opts.deferToPrompts ?? PROMPTS2;
195
+ const signature = opts.wedgeSignature ?? WEDGE_FOOTER_SIGNATURE;
196
+ const maxPolls = opts.maxPolls ?? Number.POSITIVE_INFINITY;
197
+ const now = opts.now ?? Date.now;
198
+ const sleep = opts.sleep ?? defaultSleep2;
199
+ const capture = opts.capture ?? capturePane2;
200
+ const send = opts.send ?? sendKeys2;
201
+ let stableCount = 0;
202
+ let lastKey = null;
203
+ let cooldownUntil = 0;
204
+ let fires = 0;
205
+ let polls = 0;
206
+ while (polls < maxPolls) {
207
+ polls++;
208
+ let text = "";
209
+ try {
210
+ text = capture(opts.agentName);
211
+ } catch (err) {
212
+ console.error(`[wedge-watchdog] ${opts.agentName}: capture threw: ${err.message}`);
213
+ text = "";
214
+ }
215
+ const isBlockingModal = !!text && signature.test(text) && !deferToPrompts.some((p) => p.match.test(text));
216
+ if (isBlockingModal) {
217
+ const key = stabilityKey(text);
218
+ if (key === lastKey) {
219
+ stableCount++;
220
+ } else {
221
+ stableCount = 1;
222
+ lastKey = key;
223
+ }
224
+ if (stableCount >= stabilityThreshold && now() >= cooldownUntil) {
225
+ console.error(`[wedge-watchdog] ${opts.agentName}: dismissing stuck blocking prompt ` + `(Esc) after ${stableCount} stable polls (~${stableCount * pollIntervalMs / 1000}s) \u2014 no human to answer it`);
226
+ try {
227
+ send(opts.agentName, ["Escape"]);
228
+ } catch (err) {
229
+ console.error(`[wedge-watchdog] ${opts.agentName}: send threw: ${err.message}`);
230
+ }
231
+ fires++;
232
+ cooldownUntil = now() + cooldownMs;
233
+ stableCount = 0;
234
+ lastKey = null;
235
+ }
236
+ } else {
237
+ stableCount = 0;
238
+ lastKey = null;
239
+ }
240
+ await sleep(pollIntervalMs);
241
+ }
242
+ return { fires, polls, reason: "max-polls" };
243
+ }
244
+
113
245
  // src/cli/autoaccept-poll.ts
114
246
  async function main() {
115
247
  const agentName = process.argv[2];
@@ -119,9 +251,20 @@ async function main() {
119
251
  }
120
252
  try {
121
253
  const res = await runAutoaccept({ agentName });
122
- console.error(`[autoaccept-poll] ${agentName}: done reason=${res.reason} fired=${res.fired.length ? res.fired.join(",") : "(none)"}`);
254
+ console.error(`[autoaccept-poll] ${agentName}: boot done reason=${res.reason} fired=${res.fired.length ? res.fired.join(",") : "(none)"}`);
255
+ } catch (err) {
256
+ console.error(`[autoaccept-poll] ${agentName}: boot unexpected throw: ${err.message}`);
257
+ }
258
+ if (process.env.SWITCHROOM_WEDGE_WATCHDOG === "0") {
259
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog disabled (SWITCHROOM_WEDGE_WATCHDOG=0) \u2014 exiting after boot phase`);
260
+ process.exit(0);
261
+ }
262
+ try {
263
+ console.error(`[autoaccept-poll] ${agentName}: entering wedge-watchdog (continuous)`);
264
+ const res = await runWedgeWatchdog({ agentName });
265
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog returned reason=${res.reason} fires=${res.fires}`);
123
266
  } catch (err) {
124
- console.error(`[autoaccept-poll] ${agentName}: unexpected throw: ${err.message}`);
267
+ console.error(`[autoaccept-poll] ${agentName}: wedge-watchdog unexpected throw: ${err.message}`);
125
268
  }
126
269
  process.exit(0);
127
270
  }
@@ -49420,8 +49420,8 @@ var {
49420
49420
  } = import__.default;
49421
49421
 
49422
49422
  // src/build-info.ts
49423
- var VERSION = "0.14.31";
49424
- var COMMIT_SHA = "1aa03b91";
49423
+ var VERSION = "0.14.33";
49424
+ var COMMIT_SHA = "0b73633c";
49425
49425
 
49426
49426
  // src/cli/agent.ts
49427
49427
  init_source();
@@ -50523,6 +50523,7 @@ var DEFAULT_READ_ONLY_PREAPPROVED_TOOLS = [
50523
50523
  "Skill"
50524
50524
  ];
50525
50525
  var WEBKITE_FLEET_DENY_TOOLS = ["WebFetch", "WebSearch"];
50526
+ var INTERACTIVE_TUI_FLEET_DENY_TOOLS = ["AskUserQuestion"];
50526
50527
  var WEBKITE_BINARY_CONTAINER_PATH = "/usr/local/bin/webkite";
50527
50528
  function webkiteBinaryAvailable() {
50528
50529
  const override = process.env.SWITCHROOM_WEBKITE_BINARY;
@@ -51107,7 +51108,8 @@ function buildWorkspaceContext(args) {
51107
51108
  tools,
51108
51109
  toolsDeny: dedupe2([
51109
51110
  ...tools.deny ?? [],
51110
- ...webkiteDenyForAgent(agentConfig)
51111
+ ...webkiteDenyForAgent(agentConfig),
51112
+ ...INTERACTIVE_TUI_FLEET_DENY_TOOLS
51111
51113
  ]),
51112
51114
  permissionAllow,
51113
51115
  defaultModeAcceptEdits: hasAllWildcard,
@@ -51392,6 +51394,12 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
51392
51394
  allow.push(t);
51393
51395
  }
51394
51396
  settings.permissions.allow = allow;
51397
+ const deny = Array.isArray(settings.permissions.deny) ? settings.permissions.deny : [];
51398
+ for (const t of INTERACTIVE_TUI_FLEET_DENY_TOOLS) {
51399
+ if (!deny.includes(t))
51400
+ deny.push(t);
51401
+ }
51402
+ settings.permissions.deny = deny;
51395
51403
  if (settings.mcpServers && "switchroom" in settings.mcpServers) {
51396
51404
  delete settings.mcpServers["switchroom"];
51397
51405
  }
@@ -52126,7 +52134,8 @@ function reconcileAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchr
52126
52134
  ]);
52127
52135
  const desiredDeny = dedupe2([
52128
52136
  ...tools.deny ?? [],
52129
- ...webkiteDenyForAgent(agentConfig)
52137
+ ...webkiteDenyForAgent(agentConfig),
52138
+ ...INTERACTIVE_TUI_FLEET_DENY_TOOLS
52130
52139
  ]);
52131
52140
  let topicId = agentConfig.topic_id;
52132
52141
  if (topicId === undefined) {
@@ -14918,7 +14918,11 @@ import {
14918
14918
  writeFileSync as writeFileSync3,
14919
14919
  renameSync,
14920
14920
  mkdirSync as mkdirSync2,
14921
- unlinkSync
14921
+ openSync as openSync2,
14922
+ ftruncateSync,
14923
+ writeSync,
14924
+ fsyncSync,
14925
+ closeSync as closeSync2
14922
14926
  } from "node:fs";
14923
14927
  import { join as join3, dirname as dirname4, resolve as resolve5 } from "node:path";
14924
14928
  import { randomUUID as randomUUID2, randomBytes } from "node:crypto";
@@ -20740,10 +20744,23 @@ function formatConfigApprovalDenyError(approval, approvalId) {
20740
20744
  const suffix = approval.reason ? `: ${approval.reason}` : "";
20741
20745
  return `E_DENIED: operator denied config_propose_edit${suffix} (approval_id=${approvalId})`;
20742
20746
  }
20743
- function unlinkSyncBestEffort(path2) {
20747
+ function writeFileInPlacePreservingInode(targetPath, content) {
20748
+ const buf = Buffer.from(content, "utf-8");
20749
+ const fd = openSync2(targetPath, "r+");
20744
20750
  try {
20745
- unlinkSync(path2);
20746
- } catch {}
20751
+ ftruncateSync(fd, 0);
20752
+ let off = 0;
20753
+ while (off < buf.length) {
20754
+ off += writeSync(fd, buf, off, buf.length - off, off);
20755
+ }
20756
+ fsyncSync(fd);
20757
+ } finally {
20758
+ closeSync2(fd);
20759
+ }
20760
+ const readBack = readFileSync5(targetPath);
20761
+ if (readBack.length !== buf.length) {
20762
+ throw new Error(`in-place write short: wrote ${buf.length} bytes but read back ${readBack.length}`);
20763
+ }
20747
20764
  }
20748
20765
  var STATUS_RETENTION_MS = 10 * 60 * 1000;
20749
20766
  var STATUS_MAX_ENTRIES = 256;
@@ -21341,15 +21358,15 @@ class HostdServer {
21341
21358
  return this.reconcileFailedRolledBack(`snapshot read failed: ${e.message}`, req, caller, started);
21342
21359
  }
21343
21360
  const postApply = verdict.postApplyContent;
21344
- const tmp = configPath + ".tmp";
21345
21361
  try {
21346
- writeFileSync3(tmp, postApply);
21347
- renameSync(tmp, configPath);
21362
+ writeFileInPlacePreservingInode(configPath, postApply);
21348
21363
  } catch (e) {
21349
- unlinkSyncBestEffort(tmp);
21364
+ try {
21365
+ writeFileInPlacePreservingInode(configPath, snapshot);
21366
+ } catch {}
21350
21367
  await approval.finalize({
21351
21368
  outcome: "reconcile_failed_rolled_back",
21352
- detail: `atomic write failed: ${e.message}`
21369
+ detail: `in-place write failed: ${e.message}`
21353
21370
  });
21354
21371
  return this.reconcileFailedRolledBack(`write failed: ${e.message}`, req, caller, started);
21355
21372
  }
@@ -21369,8 +21386,7 @@ class HostdServer {
21369
21386
  }
21370
21387
  let rollbackDetail = "";
21371
21388
  try {
21372
- writeFileSync3(tmp, snapshot);
21373
- renameSync(tmp, configPath);
21389
+ writeFileInPlacePreservingInode(configPath, snapshot);
21374
21390
  } catch (e) {
21375
21391
  rollbackDetail = `snapshot restore failed: ${e.message}`;
21376
21392
  await approval.finalize({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.31",
3
+ "version": "0.14.33",
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": {
@@ -111,10 +111,15 @@ if [ "$SWITCHROOM_RUNTIME" = "docker" ] && [ -z "$SWITCHROOM_DOCKER_TMUX_INNER"
111
111
  echo "[start.sh] channels.telegram.enabled=false — skipping gateway sidecar" >&2
112
112
  fi
113
113
 
114
- # 2) autoaccept-poll — first-run TUI prompt dispatcher. Single-shot
115
- # by design (exits cleanly after idle-timeout once prompts have
116
- # fired); the supervisor's exponential backoff keeps a flaky
117
- # autoaccept from busy-looping.
114
+ # 2) autoaccept-poll — first-run TUI prompt dispatcher, then continuous
115
+ # wedge-watchdog. Two phases in one process: a one-shot boot phase
116
+ # dispatches the first-run prompts and returns after idle-timeout,
117
+ # then the process stays alive running the wedge-watchdog (dismisses a
118
+ # stuck blocking modal selector mid-session — the AskUserQuestion /
119
+ # ExitPlanMode class — with Esc). So it is NORMALLY long-lived; the
120
+ # supervisor only respawns it if it crashes/exits, and its backoff
121
+ # keeps a flaky run from busy-looping. Set SWITCHROOM_WEDGE_WATCHDOG=0
122
+ # to restore the legacy boot-only single-shot behaviour.
118
123
  if [ -f /opt/switchroom/autoaccept-poll.js ] && command -v bun >/dev/null 2>&1; then
119
124
  _switchroom_supervise autoaccept /var/log/switchroom/autoaccept.log \
120
125
  bun /opt/switchroom/autoaccept-poll.js "{{name}}" &
@@ -51766,10 +51766,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51766
51766
  }
51767
51767
 
51768
51768
  // ../src/build-info.ts
51769
- var VERSION = "0.14.31";
51770
- var COMMIT_SHA = "1aa03b91";
51771
- var COMMIT_DATE = "2026-06-01T06:45:00Z";
51772
- var LATEST_PR = 2060;
51769
+ var VERSION = "0.14.33";
51770
+ var COMMIT_SHA = "0b73633c";
51771
+ var COMMIT_DATE = "2026-06-01T12:35:17Z";
51772
+ var LATEST_PR = 2066;
51773
51773
  var COMMITS_AHEAD_OF_TAG = 0;
51774
51774
 
51775
51775
  // gateway/boot-version.ts
@@ -52186,6 +52186,10 @@ function recordTurnEnd(db2, args) {
52186
52186
  WHERE turn_key = ?
52187
52187
  `).run(now, args.endedVia, args.lastAssistantMsgId ?? null, args.lastAssistantDone !== undefined ? args.lastAssistantDone ? 1 : 0 : null, args.assistantReplyPreview ?? null, args.toolCallCount !== undefined ? args.toolCallCount : null, now, args.turnKey);
52188
52188
  }
52189
+ function getTurnByKey(db2, turnKey) {
52190
+ const row = db2.prepare(`SELECT * FROM turns WHERE turn_key = ?`).get(turnKey);
52191
+ return row ? mapRow(row) : null;
52192
+ }
52189
52193
  function markOrphanedWithTimeoutClassification(db2, opts) {
52190
52194
  const now = opts.now ?? Date.now();
52191
52195
  const isHang = opts.markerAgeMs != null && opts.markerAgeMs >= opts.hangThresholdMs && opts.markerTurnKey != null && opts.markerTurnKey.length > 0;
@@ -52831,6 +52835,25 @@ try {
52831
52835
  `);
52832
52836
  turnsDb = null;
52833
52837
  }
52838
+ function resolveSubagentOriginChat(agentId) {
52839
+ if (turnsDb == null)
52840
+ return null;
52841
+ try {
52842
+ const sub = getSubagentByJsonlId(turnsDb, agentId);
52843
+ if (sub?.parent_turn_key == null)
52844
+ return null;
52845
+ const turn = getTurnByKey(turnsDb, sub.parent_turn_key);
52846
+ if (turn == null || turn.chat_id.length === 0)
52847
+ return null;
52848
+ const threadNum = turn.thread_id != null && turn.thread_id.length > 0 ? Number(turn.thread_id) : NaN;
52849
+ return {
52850
+ chatId: turn.chat_id,
52851
+ threadId: Number.isFinite(threadNum) ? threadNum : undefined
52852
+ };
52853
+ } catch {
52854
+ return null;
52855
+ }
52856
+ }
52834
52857
  var REGISTRY_REAPER_INTERVAL_MS = 21600000;
52835
52858
  function runHistoryReaperNow(reason) {
52836
52859
  const retentionDays = resolveRetentionDays(HISTORY_ACCESS.historyRetentionDays);
@@ -62616,7 +62639,7 @@ var didOneTimeSetup = false;
62616
62639
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
62617
62640
  outcome,
62618
62641
  isBackground,
62619
- fleetChatId,
62642
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
62620
62643
  ownerChatId: loadAccess().allowFrom[0] ?? "",
62621
62644
  taskDescription: description,
62622
62645
  resultText,
@@ -62695,20 +62718,21 @@ var didOneTimeSetup = false;
62695
62718
  return;
62696
62719
  }
62697
62720
  if (workerFeedEnabled) {
62698
- workerActivityFeed.update(agentId, fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
62721
+ const origin = resolveSubagentOriginChat(agentId);
62722
+ workerActivityFeed.update(agentId, origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ""), {
62699
62723
  description: dispatch.feedDescription,
62700
62724
  lastTool,
62701
62725
  toolCount,
62702
62726
  latestSummary,
62703
62727
  elapsedMs,
62704
62728
  state: "running"
62705
- });
62729
+ }, origin?.threadId);
62706
62730
  return;
62707
62731
  }
62708
62732
  const decision = decideSubagentProgress({
62709
62733
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
62710
62734
  isBackground,
62711
- fleetChatId,
62735
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
62712
62736
  ownerChatId: loadAccess().allowFrom[0] ?? "",
62713
62737
  subagentJsonlId: agentId,
62714
62738
  taskDescription: description,
@@ -427,6 +427,7 @@ import {
427
427
  recordTurnEnd,
428
428
  findLatestTurnIfInterrupted,
429
429
  findRecentTurnsForChat,
430
+ getTurnByKey,
430
431
  } from '../registry/turns-schema.js'
431
432
  import {
432
433
  buildResumeInterruptedInbound,
@@ -1117,6 +1118,41 @@ try {
1117
1118
  turnsDb = null
1118
1119
  }
1119
1120
 
1121
+ /**
1122
+ * Resolve the chat/thread a background sub-agent was dispatched from, so
1123
+ * its live worker card + handback route back to the originating
1124
+ * conversation (group / forum topic) instead of the operator DM.
1125
+ *
1126
+ * Walks jsonl_agent_id → `subagents.parent_turn_key` →
1127
+ * `turns.chat_id`/`thread_id`. Returns null on any miss so the caller
1128
+ * keeps its existing `allowFrom[0]` DM fallback — best-effort, never
1129
+ * throws out of the worker-card hot path. This restores the chat context
1130
+ * the pinned-card fleet used to carry before it was removed in #1122
1131
+ * (progressDriver is permanently null, so the old fleet lookup always
1132
+ * yielded the DM for a Task dispatched from a group/topic).
1133
+ */
1134
+ function resolveSubagentOriginChat(
1135
+ agentId: string,
1136
+ ): { chatId: string; threadId?: number } | null {
1137
+ if (turnsDb == null) return null
1138
+ try {
1139
+ const sub = getSubagentByJsonlId(turnsDb, agentId)
1140
+ if (sub?.parent_turn_key == null) return null
1141
+ const turn = getTurnByKey(turnsDb, sub.parent_turn_key)
1142
+ if (turn == null || turn.chat_id.length === 0) return null
1143
+ const threadNum =
1144
+ turn.thread_id != null && turn.thread_id.length > 0
1145
+ ? Number(turn.thread_id)
1146
+ : NaN
1147
+ return {
1148
+ chatId: turn.chat_id,
1149
+ threadId: Number.isFinite(threadNum) ? threadNum : undefined,
1150
+ }
1151
+ } catch {
1152
+ return null
1153
+ }
1154
+ }
1155
+
1120
1156
  // ─── Periodic history reaper (#1073) ──────────────────────────────────────
1121
1157
  // The init-time prune in history.ts only touched the `messages` table.
1122
1158
  // `subagents` and `turns` in registry.db grew unbounded — every Agent()
@@ -18371,11 +18407,15 @@ void (async () => {
18371
18407
  handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
18372
18408
  outcome,
18373
18409
  isBackground,
18374
- fleetChatId,
18375
- // Owner-chat fallback: if the progress-driver fleet
18376
- // entry was already cleaned up, route to the owner
18377
- // chat. Every switchroom fleet agent is DM-shaped, so
18378
- // allowFrom[0] is the conversation that dispatched.
18410
+ // Route the handback (the worker's result → a synthesized
18411
+ // turn) back to the conversation the Task was dispatched
18412
+ // from, so the result lands where the user asked — not the
18413
+ // agent's DM. Falls back to fleetChatId/ownerChatId.
18414
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18415
+ // Owner-chat fallback: if the parent-turn chat can't be
18416
+ // resolved, route to the owner chat. Every switchroom fleet
18417
+ // agent is DM-shaped, so allowFrom[0] is the conversation
18418
+ // that dispatched.
18379
18419
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18380
18420
  taskDescription: description,
18381
18421
  resultText,
@@ -18505,12 +18545,16 @@ void (async () => {
18505
18545
  // message owns the progress beat. Push a running cue and
18506
18546
  // return BEFORE the legacy bucket relay so the same activity
18507
18547
  // isn't double-surfaced (in-message edit + injected
18508
- // "still working" inbound turn). Chat = owner DM, since the
18509
- // pinned-card fleet is gone and every agent is DM-shaped.
18548
+ // "still working" inbound turn). Route to the conversation
18549
+ // the Task was dispatched from (group / forum topic) via the
18550
+ // parent turn; fall back to the owner DM when that can't be
18551
+ // resolved (the pinned-card fleet that used to carry the chat
18552
+ // is gone — see resolveSubagentOriginChat).
18510
18553
  if (workerFeedEnabled) {
18554
+ const origin = resolveSubagentOriginChat(agentId)
18511
18555
  void workerActivityFeed.update(
18512
18556
  agentId,
18513
- fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18557
+ origin?.chatId || fleetChatId || (loadAccess().allowFrom[0] ?? ''),
18514
18558
  {
18515
18559
  description: dispatch.feedDescription,
18516
18560
  lastTool,
@@ -18519,6 +18563,7 @@ void (async () => {
18519
18563
  elapsedMs,
18520
18564
  state: 'running',
18521
18565
  },
18566
+ origin?.threadId,
18522
18567
  )
18523
18568
  return
18524
18569
  }
@@ -18526,7 +18571,9 @@ void (async () => {
18526
18571
  const decision = decideSubagentProgress({
18527
18572
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
18528
18573
  isBackground,
18529
- fleetChatId,
18574
+ // Prefer the conversation the Task was dispatched from over
18575
+ // the owner DM (see resolveSubagentOriginChat).
18576
+ fleetChatId: resolveSubagentOriginChat(agentId)?.chatId || fleetChatId,
18530
18577
  ownerChatId: loadAccess().allowFrom[0] ?? '',
18531
18578
  subagentJsonlId: agentId,
18532
18579
  taskDescription: description,
@@ -20,6 +20,7 @@ import {
20
20
  recordTurnStart,
21
21
  recordTurnEnd,
22
22
  findRecentTurnsForChat,
23
+ getTurnByKey,
23
24
  } from './turns-schema.js'
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -99,3 +100,36 @@ describe('findRecentTurnsForChat', () => {
99
100
  db.close()
100
101
  })
101
102
  })
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // getTurnByKey — recover the dispatch chat/thread for a sub-agent's parent
106
+ // turn (subagents.parent_turn_key -> turns.turn_key). Without this the
107
+ // worker card / handback fall back to the operator DM (#worker-card-routing).
108
+ // ---------------------------------------------------------------------------
109
+
110
+ describe('getTurnByKey', () => {
111
+ it('returns null when the turn key does not exist', () => {
112
+ const db = openTurnsDbInMemory()
113
+ expect(getTurnByKey(db, 'nope')).toBeNull()
114
+ db.close()
115
+ })
116
+
117
+ it('recovers chat_id + thread_id for a group/topic turn', () => {
118
+ const db = openTurnsDbInMemory()
119
+ recordTurnStart(db, { turnKey: 'g:11', chatId: '-1001234567890', threadId: '42' })
120
+ const turn = getTurnByKey(db, 'g:11')
121
+ expect(turn?.turn_key).toBe('g:11')
122
+ expect(turn?.chat_id).toBe('-1001234567890')
123
+ expect(turn?.thread_id).toBe('42')
124
+ db.close()
125
+ })
126
+
127
+ it('recovers chat_id with null thread_id for a plain group/DM turn', () => {
128
+ const db = openTurnsDbInMemory()
129
+ recordTurnStart(db, { turnKey: 'dm:7', chatId: '12345' })
130
+ const turn = getTurnByKey(db, 'dm:7')
131
+ expect(turn?.chat_id).toBe('12345')
132
+ expect(turn?.thread_id).toBeNull()
133
+ db.close()
134
+ })
135
+ })
@@ -348,6 +348,24 @@ export function findOrphanedTurns(db: SqliteDatabase, chatId: string): Turn[] {
348
348
  return rows.map(mapRow)
349
349
  }
350
350
 
351
+ /**
352
+ * Fetch a single turn by its primary key, or null if absent.
353
+ *
354
+ * Used to recover the chat/thread a background sub-agent was dispatched
355
+ * from: `subagents.parent_turn_key` is an FK-by-convention to
356
+ * `turns.turn_key`, so this resolves the originating conversation
357
+ * (chat_id + thread_id) for a worker card / handback. Without it the
358
+ * worker feed falls back to the operator DM (the pinned-card fleet that
359
+ * used to carry the chat was removed in #1122), so a Task dispatched from
360
+ * a group/topic posted its progress to the agent's DM instead.
361
+ */
362
+ export function getTurnByKey(db: SqliteDatabase, turnKey: string): Turn | null {
363
+ const row = db
364
+ .prepare(`SELECT * FROM turns WHERE turn_key = ?`)
365
+ .get(turnKey) as RawTurnRow | undefined
366
+ return row ? mapRow(row) : null
367
+ }
368
+
351
369
  export interface OrphanClassifyOpts {
352
370
  /**
353
371
  * `turnKey` from the on-disk `turn-active.json` marker — the single