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.
- package/dist/cli/autoaccept-poll.js +145 -2
- package/dist/cli/switchroom.js +13 -4
- package/dist/host-control/main.js +27 -11
- package/package.json +1 -1
- package/profiles/_base/start.sh.hbs +9 -4
- package/telegram-plugin/dist/gateway/gateway.js +32 -8
- package/telegram-plugin/gateway/gateway.ts +56 -9
- package/telegram-plugin/registry/turns-schema.test.ts +34 -0
- package/telegram-plugin/registry/turns-schema.ts +18 -0
|
@@ -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
|
}
|
package/dist/cli/switchroom.js
CHANGED
|
@@ -49420,8 +49420,8 @@ var {
|
|
|
49420
49420
|
} = import__.default;
|
|
49421
49421
|
|
|
49422
49422
|
// src/build-info.ts
|
|
49423
|
-
var VERSION = "0.14.
|
|
49424
|
-
var COMMIT_SHA = "
|
|
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
|
-
|
|
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
|
|
20747
|
+
function writeFileInPlacePreservingInode(targetPath, content) {
|
|
20748
|
+
const buf = Buffer.from(content, "utf-8");
|
|
20749
|
+
const fd = openSync2(targetPath, "r+");
|
|
20744
20750
|
try {
|
|
20745
|
-
|
|
20746
|
-
|
|
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
|
-
|
|
21347
|
-
renameSync(tmp, configPath);
|
|
21362
|
+
writeFileInPlacePreservingInode(configPath, postApply);
|
|
21348
21363
|
} catch (e) {
|
|
21349
|
-
|
|
21364
|
+
try {
|
|
21365
|
+
writeFileInPlacePreservingInode(configPath, snapshot);
|
|
21366
|
+
} catch {}
|
|
21350
21367
|
await approval.finalize({
|
|
21351
21368
|
outcome: "reconcile_failed_rolled_back",
|
|
21352
|
-
detail: `
|
|
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
|
-
|
|
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
|
@@ -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
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
#
|
|
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.
|
|
51770
|
-
var COMMIT_SHA = "
|
|
51771
|
-
var COMMIT_DATE = "2026-06-
|
|
51772
|
-
var LATEST_PR =
|
|
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
|
-
|
|
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
|
-
|
|
18375
|
-
//
|
|
18376
|
-
//
|
|
18377
|
-
//
|
|
18378
|
-
|
|
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).
|
|
18509
|
-
//
|
|
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
|
-
|
|
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
|