switchroom 0.14.42 → 0.14.44
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/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +183 -17
- package/telegram-plugin/gateway/gateway.ts +100 -29
- package/telegram-plugin/gateway/subagent-handback-inbound-builder.ts +22 -0
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +13 -0
- package/telegram-plugin/gateway/turn-state-purge.ts +14 -0
- package/telegram-plugin/silence-poke.ts +26 -0
- package/telegram-plugin/status-reactions.ts +14 -0
- package/telegram-plugin/subagent-watcher.ts +44 -0
- package/telegram-plugin/tests/silence-poke.test.ts +36 -0
- package/telegram-plugin/tests/status-reactions.test.ts +16 -0
- package/telegram-plugin/tests/subagent-handback-decision.test.ts +32 -0
- package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +35 -0
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +56 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +42 -0
- package/telegram-plugin/tests/turn-state-purge.test.ts +28 -0
- package/telegram-plugin/uat/driver.ts +41 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +17 -10
- package/telegram-plugin/uat/scenarios/fuzz-supergroup-channel.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-channel.test.ts +104 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +9 -7
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-handback-channel.test.ts +77 -0
- package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +102 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-activity-feed-channel.test.ts +114 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -49462,8 +49462,8 @@ var {
|
|
|
49462
49462
|
} = import__.default;
|
|
49463
49463
|
|
|
49464
49464
|
// src/build-info.ts
|
|
49465
|
-
var VERSION = "0.14.
|
|
49466
|
-
var COMMIT_SHA = "
|
|
49465
|
+
var VERSION = "0.14.44";
|
|
49466
|
+
var COMMIT_SHA = "90836113";
|
|
49467
49467
|
|
|
49468
49468
|
// src/cli/agent.ts
|
|
49469
49469
|
init_source();
|
package/package.json
CHANGED
|
@@ -32244,6 +32244,7 @@ class StatusReactionController {
|
|
|
32244
32244
|
stallHardTimer = null;
|
|
32245
32245
|
finished = false;
|
|
32246
32246
|
held = false;
|
|
32247
|
+
awaitingApproval = false;
|
|
32247
32248
|
debounceMs;
|
|
32248
32249
|
stallSoftMs;
|
|
32249
32250
|
stallHardMs;
|
|
@@ -32305,9 +32306,13 @@ class StatusReactionController {
|
|
|
32305
32306
|
this.enqueue(working);
|
|
32306
32307
|
}
|
|
32307
32308
|
}
|
|
32309
|
+
isAwaiting() {
|
|
32310
|
+
return this.awaitingApproval && !this.finished;
|
|
32311
|
+
}
|
|
32308
32312
|
scheduleState(state, opts = {}) {
|
|
32309
32313
|
if (this.finished)
|
|
32310
32314
|
return;
|
|
32315
|
+
this.awaitingApproval = state === "awaiting";
|
|
32311
32316
|
const emoji = this.resolveEmoji(state);
|
|
32312
32317
|
if (emoji == null) {
|
|
32313
32318
|
if (!opts.skipStallReset)
|
|
@@ -38957,8 +38962,17 @@ function noteToolEnd(key, toolUseId, _now) {
|
|
|
38957
38962
|
function endTurn(key) {
|
|
38958
38963
|
state2.delete(key);
|
|
38959
38964
|
}
|
|
38960
|
-
function
|
|
38965
|
+
function silenceMsForKey(key, now) {
|
|
38966
|
+
const s = state2.get(key);
|
|
38967
|
+
if (s == null)
|
|
38968
|
+
return null;
|
|
38969
|
+
return now - (s.lastOutboundAt ?? s.turnStartedAt);
|
|
38970
|
+
}
|
|
38971
|
+
function formatFrameworkFallbackText(fallbackKind, silenceMs, inFlightTools = [], blockedOnApproval = false) {
|
|
38961
38972
|
const minutes = Math.max(1, Math.round(silenceMs / 60000));
|
|
38973
|
+
if (blockedOnApproval) {
|
|
38974
|
+
return `waiting for your approval \u2014 tap Approve or Deny on the card above (${minutes} min)`;
|
|
38975
|
+
}
|
|
38962
38976
|
const suffix = `(no update from agent in ${minutes} min)`;
|
|
38963
38977
|
if (inFlightTools.length > 0) {
|
|
38964
38978
|
const longest = inFlightTools[0];
|
|
@@ -47234,7 +47248,7 @@ function createInboundSpool(opts) {
|
|
|
47234
47248
|
}
|
|
47235
47249
|
|
|
47236
47250
|
// gateway/turn-state-purge.ts
|
|
47237
|
-
function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
47251
|
+
function purgeStaleTurnsForChat(chatId, keys, purger, isStale = () => true) {
|
|
47238
47252
|
if (!chatId)
|
|
47239
47253
|
return { purged: [] };
|
|
47240
47254
|
const purged = [];
|
|
@@ -47248,6 +47262,8 @@ function purgeStaleTurnsForChat(chatId, keys, purger) {
|
|
|
47248
47262
|
const keyChat = key.slice(0, sep3);
|
|
47249
47263
|
if (keyChat !== chatId)
|
|
47250
47264
|
continue;
|
|
47265
|
+
if (!isStale(key))
|
|
47266
|
+
continue;
|
|
47251
47267
|
purger(key);
|
|
47252
47268
|
purged.push(key);
|
|
47253
47269
|
}
|
|
@@ -47942,6 +47958,7 @@ ${result}
|
|
|
47942
47958
|
return {
|
|
47943
47959
|
type: "inbound",
|
|
47944
47960
|
chatId: opts.ctx.chatId,
|
|
47961
|
+
...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
|
|
47945
47962
|
messageId: ts,
|
|
47946
47963
|
user: "subagent-watcher",
|
|
47947
47964
|
userId: 0,
|
|
@@ -47950,6 +47967,7 @@ ${result}
|
|
|
47950
47967
|
meta: {
|
|
47951
47968
|
source: "subagent_handback",
|
|
47952
47969
|
outcome: opts.ctx.outcome,
|
|
47970
|
+
...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
|
|
47953
47971
|
...opts.ctx.jsonlAgentId ? { subagent_jsonl_id: opts.ctx.jsonlAgentId } : {}
|
|
47954
47972
|
}
|
|
47955
47973
|
};
|
|
@@ -47968,9 +47986,11 @@ function decideSubagentHandback(input) {
|
|
|
47968
47986
|
if (!chatId) {
|
|
47969
47987
|
return { deliver: false, reason: "no-chat" };
|
|
47970
47988
|
}
|
|
47989
|
+
const threadId = input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined;
|
|
47971
47990
|
const inbound = buildSubagentHandbackInbound({
|
|
47972
47991
|
ctx: {
|
|
47973
47992
|
chatId,
|
|
47993
|
+
...threadId != null ? { threadId } : {},
|
|
47974
47994
|
taskDescription: input.taskDescription,
|
|
47975
47995
|
resultText: input.resultText,
|
|
47976
47996
|
outcome: input.outcome,
|
|
@@ -48017,6 +48037,7 @@ ${summary}
|
|
|
48017
48037
|
return {
|
|
48018
48038
|
type: "inbound",
|
|
48019
48039
|
chatId: opts.ctx.chatId,
|
|
48040
|
+
...opts.ctx.threadId != null ? { threadId: opts.ctx.threadId } : {},
|
|
48020
48041
|
messageId: ts,
|
|
48021
48042
|
user: "subagent-watcher",
|
|
48022
48043
|
userId: 0,
|
|
@@ -48024,6 +48045,7 @@ ${summary}
|
|
|
48024
48045
|
text,
|
|
48025
48046
|
meta: {
|
|
48026
48047
|
source: "subagent_progress",
|
|
48048
|
+
...opts.ctx.threadId != null ? { message_thread_id: String(opts.ctx.threadId) } : {},
|
|
48027
48049
|
subagent_jsonl_id: opts.ctx.subagentJsonlId,
|
|
48028
48050
|
bucket_idx: String(opts.ctx.bucketIdx),
|
|
48029
48051
|
expiresAt: String(expiresAt),
|
|
@@ -48062,9 +48084,11 @@ function decideSubagentProgress(input) {
|
|
|
48062
48084
|
if (input.lastBucketIdx != null && bucketIdx <= input.lastBucketIdx) {
|
|
48063
48085
|
return { deliver: false, reason: "bucket-already-fired" };
|
|
48064
48086
|
}
|
|
48087
|
+
const threadId = input.fleetChatId && input.originThreadId != null ? input.originThreadId : undefined;
|
|
48065
48088
|
const inbound = buildSubagentProgressInbound({
|
|
48066
48089
|
ctx: {
|
|
48067
48090
|
chatId,
|
|
48091
|
+
...threadId != null ? { threadId } : {},
|
|
48068
48092
|
subagentJsonlId: input.subagentJsonlId,
|
|
48069
48093
|
taskDescription: input.taskDescription,
|
|
48070
48094
|
latestSummary: input.latestSummary,
|
|
@@ -49498,6 +49522,113 @@ function redactSecrets(text) {
|
|
|
49498
49522
|
out = out.replace(re, "[redacted]");
|
|
49499
49523
|
return out;
|
|
49500
49524
|
}
|
|
49525
|
+
|
|
49526
|
+
// tool-activity-summary.ts
|
|
49527
|
+
function baseName(p) {
|
|
49528
|
+
if (typeof p !== "string" || p.length === 0)
|
|
49529
|
+
return null;
|
|
49530
|
+
const parts = p.split("/").filter(Boolean);
|
|
49531
|
+
return parts.length > 0 ? parts[parts.length - 1] : p;
|
|
49532
|
+
}
|
|
49533
|
+
function hostName(u) {
|
|
49534
|
+
if (typeof u !== "string" || u.length === 0)
|
|
49535
|
+
return null;
|
|
49536
|
+
try {
|
|
49537
|
+
return new URL(u).hostname.replace(/^www\./, "");
|
|
49538
|
+
} catch {
|
|
49539
|
+
return u.replace(/^https?:\/\//, "").split("/")[0] || null;
|
|
49540
|
+
}
|
|
49541
|
+
}
|
|
49542
|
+
function clip(s, n) {
|
|
49543
|
+
if (typeof s !== "string")
|
|
49544
|
+
return null;
|
|
49545
|
+
const t = s.trim();
|
|
49546
|
+
if (t.length === 0)
|
|
49547
|
+
return null;
|
|
49548
|
+
return t.length > n ? t.slice(0, n - 1) + "\u2026" : t;
|
|
49549
|
+
}
|
|
49550
|
+
function describeToolUse(toolName, input) {
|
|
49551
|
+
if (!toolName)
|
|
49552
|
+
return null;
|
|
49553
|
+
const inp = input ?? {};
|
|
49554
|
+
const mcpMatch = /^mcp__(.+?)__(.+)$/.exec(toolName);
|
|
49555
|
+
if (mcpMatch) {
|
|
49556
|
+
const server = mcpMatch[1].toLowerCase();
|
|
49557
|
+
const tool = mcpMatch[2].toLowerCase();
|
|
49558
|
+
if (server === "switchroom-telegram")
|
|
49559
|
+
return null;
|
|
49560
|
+
if (server === "hindsight") {
|
|
49561
|
+
if (tool === "recall" || tool === "reflect")
|
|
49562
|
+
return "Searching memory";
|
|
49563
|
+
if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
|
|
49564
|
+
return "Saving to memory";
|
|
49565
|
+
return "Working with memory";
|
|
49566
|
+
}
|
|
49567
|
+
if (server === "google-workspace" || server === "claude_ai_google_calendar") {
|
|
49568
|
+
return "Checking your calendar";
|
|
49569
|
+
}
|
|
49570
|
+
if (server === "claude_ai_gmail")
|
|
49571
|
+
return "Checking your email";
|
|
49572
|
+
if (server === "claude_ai_google_drive")
|
|
49573
|
+
return "Looking through your files";
|
|
49574
|
+
if (server === "notion" || server === "claude_ai_notion") {
|
|
49575
|
+
return "Checking your notes";
|
|
49576
|
+
}
|
|
49577
|
+
const desc = clip(inp.description, 60) ?? clip(inp.query, 50) ?? clip(inp.title, 50);
|
|
49578
|
+
if (desc)
|
|
49579
|
+
return desc;
|
|
49580
|
+
return "Using " + tool.replace(/[-_]+/g, " ");
|
|
49581
|
+
}
|
|
49582
|
+
switch (toolName) {
|
|
49583
|
+
case "Bash": {
|
|
49584
|
+
return clip(inp.description, 70) ?? "Running a command";
|
|
49585
|
+
}
|
|
49586
|
+
case "BashOutput":
|
|
49587
|
+
case "KillShell":
|
|
49588
|
+
return "Managing a background command";
|
|
49589
|
+
case "Read": {
|
|
49590
|
+
const f = baseName(inp.file_path);
|
|
49591
|
+
return f ? `Reading ${f}` : "Reading a file";
|
|
49592
|
+
}
|
|
49593
|
+
case "Edit":
|
|
49594
|
+
case "MultiEdit":
|
|
49595
|
+
case "NotebookEdit": {
|
|
49596
|
+
const f = baseName(inp.file_path) ?? baseName(inp.notebook_path);
|
|
49597
|
+
return f ? `Editing ${f}` : "Editing a file";
|
|
49598
|
+
}
|
|
49599
|
+
case "Write": {
|
|
49600
|
+
const f = baseName(inp.file_path);
|
|
49601
|
+
return f ? `Writing ${f}` : "Writing a file";
|
|
49602
|
+
}
|
|
49603
|
+
case "Grep":
|
|
49604
|
+
case "Glob": {
|
|
49605
|
+
const p = clip(inp.pattern, 40);
|
|
49606
|
+
return p ? `Searching for ${p}` : "Searching files";
|
|
49607
|
+
}
|
|
49608
|
+
case "WebFetch": {
|
|
49609
|
+
const h = hostName(inp.url);
|
|
49610
|
+
return h ? `Reading ${h}` : "Reading a web page";
|
|
49611
|
+
}
|
|
49612
|
+
case "WebSearch": {
|
|
49613
|
+
const q = clip(inp.query, 50);
|
|
49614
|
+
return q ? `Searching the web for ${q}` : "Searching the web";
|
|
49615
|
+
}
|
|
49616
|
+
case "Task":
|
|
49617
|
+
case "Agent": {
|
|
49618
|
+
const d = clip(inp.description, 60);
|
|
49619
|
+
return d ? `Delegating: ${d}` : "Delegating to a sub-agent";
|
|
49620
|
+
}
|
|
49621
|
+
case "TodoWrite":
|
|
49622
|
+
case "TaskCreate":
|
|
49623
|
+
case "TaskUpdate":
|
|
49624
|
+
case "TaskList":
|
|
49625
|
+
return "Updating the plan";
|
|
49626
|
+
case "ToolSearch":
|
|
49627
|
+
return "Finding the right tool";
|
|
49628
|
+
default:
|
|
49629
|
+
return "Working\u2026";
|
|
49630
|
+
}
|
|
49631
|
+
}
|
|
49501
49632
|
// registry/subagents-schema.ts
|
|
49502
49633
|
function countRunningBackgroundSubagents(db2) {
|
|
49503
49634
|
const row = db2.prepare("SELECT count(*) AS n FROM subagents WHERE background = 1 AND status = 'running'").get();
|
|
@@ -49745,6 +49876,28 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
|
|
|
49745
49876
|
name: ev.toolName,
|
|
49746
49877
|
sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {})
|
|
49747
49878
|
};
|
|
49879
|
+
if (onProgress != null && entry.state === "running" && !entry.historical) {
|
|
49880
|
+
const toolLine = describeToolUse(ev.toolName, ev.input ?? {});
|
|
49881
|
+
if (toolLine != null && toolLine.length > 0) {
|
|
49882
|
+
try {
|
|
49883
|
+
onProgress({
|
|
49884
|
+
agentId: entry.agentId,
|
|
49885
|
+
description: entry.description,
|
|
49886
|
+
latestSummary: entry.lastResultText,
|
|
49887
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
49888
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
49889
|
+
setBucketIdx: (b) => {
|
|
49890
|
+
entry.lastProgressBucketIdx = b;
|
|
49891
|
+
},
|
|
49892
|
+
lastTool: entry.lastTool,
|
|
49893
|
+
toolCount: entry.toolCount,
|
|
49894
|
+
progressLine: toolLine
|
|
49895
|
+
});
|
|
49896
|
+
} catch (cbErr) {
|
|
49897
|
+
log?.(`subagent-watcher: onProgress (tool) callback error ${entry.agentId}: ${cbErr.message}`);
|
|
49898
|
+
}
|
|
49899
|
+
}
|
|
49900
|
+
}
|
|
49748
49901
|
} else if (ev.kind === "sub_agent_text") {
|
|
49749
49902
|
entry.lastSummaryLine = ev.text.split(`
|
|
49750
49903
|
`)[0].trim().slice(0, 120);
|
|
@@ -51871,10 +52024,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
|
|
|
51871
52024
|
}
|
|
51872
52025
|
|
|
51873
52026
|
// ../src/build-info.ts
|
|
51874
|
-
var VERSION = "0.14.
|
|
51875
|
-
var COMMIT_SHA = "
|
|
51876
|
-
var COMMIT_DATE = "2026-06-
|
|
51877
|
-
var LATEST_PR =
|
|
52027
|
+
var VERSION = "0.14.44";
|
|
52028
|
+
var COMMIT_SHA = "90836113";
|
|
52029
|
+
var COMMIT_DATE = "2026-06-03T02:30:59Z";
|
|
52030
|
+
var LATEST_PR = 2110;
|
|
51878
52031
|
var COMMITS_AHEAD_OF_TAG = 0;
|
|
51879
52032
|
|
|
51880
52033
|
// gateway/boot-version.ts
|
|
@@ -54096,7 +54249,8 @@ startTimer({
|
|
|
54096
54249
|
} catch {}
|
|
54097
54250
|
}
|
|
54098
54251
|
if (text == null) {
|
|
54099
|
-
|
|
54252
|
+
const blockedOnApproval = activeStatusReactions.get(statusKey(ctx.chatId, ctx.threadId))?.isAwaiting() ?? false;
|
|
54253
|
+
text = formatFrameworkFallbackText(ctx.fallbackKind, ctx.silenceMs, ctx.inFlightTools, blockedOnApproval);
|
|
54100
54254
|
}
|
|
54101
54255
|
try {
|
|
54102
54256
|
await robustApiCall(() => bot.api.sendMessage(ctx.chatId, text, {
|
|
@@ -54155,7 +54309,13 @@ startTimer({
|
|
|
54155
54309
|
endTurn(fbKey);
|
|
54156
54310
|
noteTurnEnd(fbKey);
|
|
54157
54311
|
purgeReactionTracking(fbKey);
|
|
54158
|
-
const
|
|
54312
|
+
const fbNow = Date.now();
|
|
54313
|
+
const fbExtraPurge = purgeStaleTurnsForChat(fbChatId, activeTurnStartedAt.keys(), purgeReactionTracking, (siblingKey) => {
|
|
54314
|
+
if (siblingKey === fbKey)
|
|
54315
|
+
return true;
|
|
54316
|
+
const sib = silenceMsForKey(siblingKey, fbNow);
|
|
54317
|
+
return sib == null || sib >= DEFAULT_THRESHOLDS.fallback;
|
|
54318
|
+
});
|
|
54159
54319
|
if (turnMatchesFallback && currentTurn === wedgedTurn)
|
|
54160
54320
|
currentTurn = null;
|
|
54161
54321
|
try {
|
|
@@ -55075,7 +55235,7 @@ ${url}`;
|
|
|
55075
55235
|
effectiveText = text;
|
|
55076
55236
|
}
|
|
55077
55237
|
assertAllowedChat(chat_id);
|
|
55078
|
-
let threadId = resolveThreadId(chat_id, args.message_thread_id);
|
|
55238
|
+
let threadId = resolveThreadId(chat_id, args.message_thread_id ?? (turn?.sessionThreadId != null ? turn.sessionThreadId : undefined));
|
|
55079
55239
|
if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
|
|
55080
55240
|
try {
|
|
55081
55241
|
const latest = getLatestInboundMessageId(chat_id, threadId ?? null);
|
|
@@ -55414,6 +55574,9 @@ async function executeStreamReply(args) {
|
|
|
55414
55574
|
throw new Error("stream_reply: chat_id is required");
|
|
55415
55575
|
if (args.text == null || args.text === "")
|
|
55416
55576
|
throw new Error("stream_reply: text is required and cannot be empty");
|
|
55577
|
+
if (args.message_thread_id == null && turn?.sessionThreadId != null) {
|
|
55578
|
+
args.message_thread_id = String(turn.sessionThreadId);
|
|
55579
|
+
}
|
|
55417
55580
|
args.text = redactOutboundText(args.text, "stream_reply");
|
|
55418
55581
|
{
|
|
55419
55582
|
const scrub = scrubVoice(args.text);
|
|
@@ -56676,13 +56839,12 @@ function handleSessionEvent(ev) {
|
|
|
56676
56839
|
const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId));
|
|
56677
56840
|
const name = ev.toolName;
|
|
56678
56841
|
if (isTelegramReplyTool(name)) {
|
|
56679
|
-
const wasFirstReply = !turn.replyCalled;
|
|
56680
56842
|
turn.replyCalled = true;
|
|
56681
56843
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
56682
56844
|
clearTimeout(turn.orphanedReplyTimeoutId);
|
|
56683
56845
|
turn.orphanedReplyTimeoutId = null;
|
|
56684
56846
|
}
|
|
56685
|
-
if (
|
|
56847
|
+
if (turn.finalAnswerDelivered) {
|
|
56686
56848
|
clearActivitySummary(turn);
|
|
56687
56849
|
}
|
|
56688
56850
|
}
|
|
@@ -56702,7 +56864,7 @@ function handleSessionEvent(ev) {
|
|
|
56702
56864
|
return;
|
|
56703
56865
|
if (isTelegramSurfaceTool(ev.toolName))
|
|
56704
56866
|
return;
|
|
56705
|
-
if (turn.
|
|
56867
|
+
if (turn.finalAnswerDelivered)
|
|
56706
56868
|
return;
|
|
56707
56869
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
|
|
56708
56870
|
if (rendered != null) {
|
|
@@ -62863,11 +63025,13 @@ var didOneTimeSetup = false;
|
|
|
62863
63025
|
state: outcome === "failed" ? "failed" : "done"
|
|
62864
63026
|
});
|
|
62865
63027
|
}
|
|
63028
|
+
const handbackOrigin = resolveSubagentOriginChat(agentId);
|
|
62866
63029
|
const decision = decideSubagentHandback({
|
|
62867
63030
|
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
62868
63031
|
outcome,
|
|
62869
63032
|
isBackground,
|
|
62870
|
-
fleetChatId:
|
|
63033
|
+
fleetChatId: handbackOrigin?.chatId || fleetChatId,
|
|
63034
|
+
...handbackOrigin?.threadId != null ? { originThreadId: handbackOrigin.threadId } : {},
|
|
62871
63035
|
ownerChatId: loadAccess().allowFrom[0] ?? "",
|
|
62872
63036
|
taskDescription: description,
|
|
62873
63037
|
resultText,
|
|
@@ -62895,7 +63059,7 @@ var didOneTimeSetup = false;
|
|
|
62895
63059
|
process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
|
|
62896
63060
|
`);
|
|
62897
63061
|
},
|
|
62898
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
|
|
63062
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
62899
63063
|
let fleetChatId = "";
|
|
62900
63064
|
try {
|
|
62901
63065
|
const fleets = progressDriver?.peekAllFleets() ?? [];
|
|
@@ -62922,7 +63086,7 @@ var didOneTimeSetup = false;
|
|
|
62922
63086
|
replyCalled: turn.replyCalled
|
|
62923
63087
|
}))
|
|
62924
63088
|
return;
|
|
62925
|
-
const child = latestSummary.trim().slice(0, 120);
|
|
63089
|
+
const child = (progressLine ?? latestSummary).trim().slice(0, 120);
|
|
62926
63090
|
if (child.length === 0)
|
|
62927
63091
|
return;
|
|
62928
63092
|
let narrative = turn.foregroundSubAgents.get(agentId);
|
|
@@ -62957,10 +63121,12 @@ var didOneTimeSetup = false;
|
|
|
62957
63121
|
}, origin?.threadId);
|
|
62958
63122
|
return;
|
|
62959
63123
|
}
|
|
63124
|
+
const progressOrigin = resolveSubagentOriginChat(agentId);
|
|
62960
63125
|
const decision = decideSubagentProgress({
|
|
62961
63126
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
62962
63127
|
isBackground,
|
|
62963
|
-
fleetChatId:
|
|
63128
|
+
fleetChatId: progressOrigin?.chatId || fleetChatId,
|
|
63129
|
+
...progressOrigin?.threadId != null ? { originThreadId: progressOrigin.threadId } : {},
|
|
62964
63130
|
ownerChatId: loadAccess().allowFrom[0] ?? "",
|
|
62965
63131
|
subagentJsonlId: agentId,
|
|
62966
63132
|
taskDescription: description,
|
|
@@ -62973,7 +63139,7 @@ var didOneTimeSetup = false;
|
|
|
62973
63139
|
return;
|
|
62974
63140
|
setBucketIdx(decision.bucketIdx);
|
|
62975
63141
|
pendingInboundBuffer.push(process.env.SWITCHROOM_AGENT_NAME ?? "", decision.inbound);
|
|
62976
|
-
clearPending(statusKey(decision.chatId,
|
|
63142
|
+
clearPending(statusKey(decision.chatId, progressOrigin?.threadId), "progress");
|
|
62977
63143
|
process.stderr.write(`telegram gateway: subagent-progress queued agent=${agentId} bucket=${decision.bucketIdx} elapsed_ms=${elapsedMs} chat=${decision.chatId}
|
|
62978
63144
|
`);
|
|
62979
63145
|
}
|
|
@@ -3912,10 +3912,18 @@ silencePoke.startTimer({
|
|
|
3912
3912
|
// (CC-4 in `docs/status-ask-cause-classes.md`). Derives "N min" suffix
|
|
3913
3913
|
// from `ctx.silenceMs` so the wording stays honest if the 300s
|
|
3914
3914
|
// threshold is tuned.
|
|
3915
|
+
// Honesty: if the turn is parked on an approval card (the dominant
|
|
3916
|
+
// benign "wedge" class — claude is alive, waiting on the operator's
|
|
3917
|
+
// tap), say so instead of "still working…". The reaction controller
|
|
3918
|
+
// already tracks this (setAwaiting on the permission-request park).
|
|
3919
|
+
const blockedOnApproval = activeStatusReactions
|
|
3920
|
+
.get(statusKey(ctx.chatId, ctx.threadId))
|
|
3921
|
+
?.isAwaiting() ?? false
|
|
3915
3922
|
text = silencePoke.formatFrameworkFallbackText(
|
|
3916
3923
|
ctx.fallbackKind,
|
|
3917
3924
|
ctx.silenceMs,
|
|
3918
3925
|
ctx.inFlightTools,
|
|
3926
|
+
blockedOnApproval,
|
|
3919
3927
|
)
|
|
3920
3928
|
}
|
|
3921
3929
|
try {
|
|
@@ -4033,16 +4041,29 @@ silencePoke.startTimer({
|
|
|
4033
4041
|
// SAME chat (different threads, or a `null` vs `undefined`-thread
|
|
4034
4042
|
// variant left over from a normal turn-end path that nulled
|
|
4035
4043
|
// currentTurn without invoking purgeReactionTracking — the
|
|
4036
|
-
// gymbro/klanker held-mid-turn symptom, 2026-05-20)
|
|
4037
|
-
//
|
|
4038
|
-
// (the chat has been silent ≥5 min); sweep them via the same
|
|
4039
|
-
// purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
4044
|
+
// gymbro/klanker held-mid-turn symptom, 2026-05-20); sweep them via
|
|
4045
|
+
// the same purger. Multi-chat-safe — only touches keys for fbChatId, so
|
|
4040
4046
|
// #1546's intentional cross-chat safety guard is preserved.
|
|
4047
|
+
//
|
|
4048
|
+
// BUT a sibling is NOT "by definition stale": in one-agent-owns-supergroup
|
|
4049
|
+
// every forum topic shares fbChatId, so a chatId-only sweep would purge a
|
|
4050
|
+
// LIVE sibling topic's reaction controller + typing loop when THIS topic's
|
|
4051
|
+
// poke fires. Gate each sibling on its OWN silence clock — purge only those
|
|
4052
|
+
// also silent ≥ the fallback threshold (their own poke would fire too),
|
|
4053
|
+
// sparing topics that are actively mid-turn. Use silence, not turn-start
|
|
4054
|
+
// age, so a long-but-narrating turn isn't mistaken for stale.
|
|
4041
4055
|
// See turn-state-purge.ts.
|
|
4056
|
+
const fbNow = Date.now()
|
|
4042
4057
|
const fbExtraPurge = purgeStaleTurnsForChat(
|
|
4043
4058
|
fbChatId,
|
|
4044
4059
|
activeTurnStartedAt.keys(),
|
|
4045
4060
|
purgeReactionTracking,
|
|
4061
|
+
(siblingKey) => {
|
|
4062
|
+
if (siblingKey === fbKey) return true // the firing key is genuinely stale
|
|
4063
|
+
const sib = silencePoke.silenceMsForKey(siblingKey, fbNow)
|
|
4064
|
+
// No silence-poke state → dangling (turn ended, key not purged) → stale.
|
|
4065
|
+
return sib == null || sib >= silencePoke.DEFAULT_THRESHOLDS.fallback
|
|
4066
|
+
},
|
|
4046
4067
|
)
|
|
4047
4068
|
// Null `currentTurn` if it's still pointing at the wedged turn —
|
|
4048
4069
|
// when claude eventually fires a late `turn_end` for this session
|
|
@@ -5603,7 +5624,21 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
|
|
|
5603
5624
|
|
|
5604
5625
|
assertAllowedChat(chat_id)
|
|
5605
5626
|
|
|
5606
|
-
|
|
5627
|
+
// Thread resolution precedence: (1) an explicit message_thread_id the
|
|
5628
|
+
// model passed, else (2) THIS turn's own originating topic
|
|
5629
|
+
// (turn-pinned, #1664), else (3) the chat's last-seen topic
|
|
5630
|
+
// (chatThreadMap). Preferring the turn's own thread over the chat
|
|
5631
|
+
// last-seen heuristic fixes synthetic turns (subagent handback/progress,
|
|
5632
|
+
// cron) — whose topic the model is never told and which never write
|
|
5633
|
+
// chatThreadMap — and is strictly more correct under multi-topic
|
|
5634
|
+
// concurrency (a reply lands in the topic the turn came from, not
|
|
5635
|
+
// whichever topic most recently received a message). DM: both are
|
|
5636
|
+
// undefined → unchanged.
|
|
5637
|
+
let threadId = resolveThreadId(
|
|
5638
|
+
chat_id,
|
|
5639
|
+
(args.message_thread_id as string | undefined) ??
|
|
5640
|
+
(turn?.sessionThreadId != null ? turn.sessionThreadId : undefined),
|
|
5641
|
+
)
|
|
5607
5642
|
|
|
5608
5643
|
if (reply_to == null && quoteOptIn && HISTORY_ENABLED) {
|
|
5609
5644
|
try {
|
|
@@ -6202,6 +6237,16 @@ async function executeStreamReply(args: Record<string, unknown>): Promise<unknow
|
|
|
6202
6237
|
const turn = currentTurn
|
|
6203
6238
|
if (!args.chat_id) throw new Error('stream_reply: chat_id is required')
|
|
6204
6239
|
if (args.text == null || args.text === '') throw new Error('stream_reply: text is required and cannot be empty')
|
|
6240
|
+
// Thread precedence (matches executeReply): when the model passes no
|
|
6241
|
+
// explicit message_thread_id, fall back to THIS turn's originating
|
|
6242
|
+
// topic before handleStreamReply's chatThreadMap last-seen heuristic.
|
|
6243
|
+
// Injecting here threads every downstream consumer consistently — the
|
|
6244
|
+
// dedup key, the voice-scrub metric, the draft transport, and the send
|
|
6245
|
+
// — so a streamed handback/synthetic-turn reply lands in the right
|
|
6246
|
+
// supergroup topic. DM: sessionThreadId undefined → unchanged.
|
|
6247
|
+
if (args.message_thread_id == null && turn?.sessionThreadId != null) {
|
|
6248
|
+
args.message_thread_id = String(turn.sessionThreadId)
|
|
6249
|
+
}
|
|
6205
6250
|
|
|
6206
6251
|
// Outbound secret scrub (#2044): mask before the dedup key, the draft
|
|
6207
6252
|
// stream sends, and the history record. stream_reply carries the FULL
|
|
@@ -8130,17 +8175,19 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
8130
8175
|
// Phase tracking removed in #553 PR 5 — phases only fed the
|
|
8131
8176
|
// placeholder-heartbeat label, which has been retired.
|
|
8132
8177
|
if (isTelegramReplyTool(name)) {
|
|
8133
|
-
const wasFirstReply = !turn.replyCalled
|
|
8134
8178
|
turn.replyCalled = true
|
|
8135
8179
|
if (turn.orphanedReplyTimeoutId != null) {
|
|
8136
8180
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
8137
8181
|
turn.orphanedReplyTimeoutId = null
|
|
8138
8182
|
}
|
|
8139
|
-
//
|
|
8140
|
-
//
|
|
8141
|
-
//
|
|
8142
|
-
//
|
|
8143
|
-
|
|
8183
|
+
// Delete the activity feed only when the FINAL answer has landed —
|
|
8184
|
+
// NOT on an ack-first interim reply ("On it"). Gating on the first
|
|
8185
|
+
// reply deleted the feed on the ack, so the post-ack work
|
|
8186
|
+
// (sub-agents/tools) rendered into nothing — the "agent went silent
|
|
8187
|
+
// after On it" gap. `finalAnswerDelivered` is set by executeReply
|
|
8188
|
+
// (isFinalAnswerReply) before this tool_use event fires; turn_end
|
|
8189
|
+
// (below) clears unconditionally as the idempotent no-reply / race net.
|
|
8190
|
+
if (turn.finalAnswerDelivered) {
|
|
8144
8191
|
clearActivitySummary(turn)
|
|
8145
8192
|
}
|
|
8146
8193
|
}
|
|
@@ -8171,15 +8218,16 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
8171
8218
|
// Surface tools (reply/stream_reply/react) are the conversation, not
|
|
8172
8219
|
// activity — the hook labels them ("Replying"), so filter by name.
|
|
8173
8220
|
if (isTelegramSurfaceTool(ev.toolName)) return
|
|
8174
|
-
// Stop feeding once the
|
|
8175
|
-
//
|
|
8176
|
-
//
|
|
8177
|
-
//
|
|
8178
|
-
//
|
|
8221
|
+
// Stop feeding once the FINAL answer has landed — the hand-off where
|
|
8222
|
+
// `clearActivitySummary` deletes the feed so the answer is the
|
|
8223
|
+
// authoritative surface. Gating on `replyCalled` (any reply) killed the
|
|
8224
|
+
// feed on an ack-first interim "On it", so the post-ack work had no live
|
|
8225
|
+
// surface; gate on `finalAnswerDelivered` so the feed keeps narrating
|
|
8226
|
+
// between the ack and the real answer. Without this a tool called after
|
|
8227
|
+
// the FINAL answer would re-`sendMessage` a fresh feed below it (flicker).
|
|
8179
8228
|
// Safe ordering: `tool_label` is real-time (PreToolUse, ~250ms) while
|
|
8180
|
-
// `
|
|
8181
|
-
|
|
8182
|
-
if (turn.replyCalled) return
|
|
8229
|
+
// `finalAnswerDelivered` is set from executeReply on the final answer.
|
|
8230
|
+
if (turn.finalAnswerDelivered) return
|
|
8183
8231
|
const rendered = appendActivityLabel(turn.mirrorLines, ev.label)
|
|
8184
8232
|
if (rendered != null) {
|
|
8185
8233
|
// Recompose so any active foreground sub-agent's nested block (Model A)
|
|
@@ -18631,6 +18679,7 @@ void (async () => {
|
|
|
18631
18679
|
})
|
|
18632
18680
|
}
|
|
18633
18681
|
|
|
18682
|
+
const handbackOrigin = resolveSubagentOriginChat(agentId)
|
|
18634
18683
|
const decision = decideSubagentHandback({
|
|
18635
18684
|
handbackEnvValue: process.env.SWITCHROOM_SUBAGENT_HANDBACK,
|
|
18636
18685
|
outcome,
|
|
@@ -18639,11 +18688,18 @@ void (async () => {
|
|
|
18639
18688
|
// turn) back to the conversation the Task was dispatched
|
|
18640
18689
|
// from, so the result lands where the user asked — not the
|
|
18641
18690
|
// agent's DM. Falls back to fleetChatId/ownerChatId.
|
|
18642
|
-
fleetChatId:
|
|
18691
|
+
fleetChatId: handbackOrigin?.chatId || fleetChatId,
|
|
18692
|
+
// Supergroup topic the Task was dispatched from. Plumbed
|
|
18693
|
+
// through so the handback turn (and the model's in-voice
|
|
18694
|
+
// "here's what the worker found" reply) land in the
|
|
18695
|
+
// originating topic — not the chat's last-seen topic.
|
|
18696
|
+
// Applied only when the origin chat resolved (DM fallback
|
|
18697
|
+
// is topic-less).
|
|
18698
|
+
...(handbackOrigin?.threadId != null
|
|
18699
|
+
? { originThreadId: handbackOrigin.threadId }
|
|
18700
|
+
: {}),
|
|
18643
18701
|
// Owner-chat fallback: if the parent-turn chat can't be
|
|
18644
|
-
// resolved, route to the owner chat.
|
|
18645
|
-
// agent is DM-shaped, so allowFrom[0] is the conversation
|
|
18646
|
-
// that dispatched.
|
|
18702
|
+
// resolved, route to the owner chat.
|
|
18647
18703
|
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
18648
18704
|
taskDescription: description,
|
|
18649
18705
|
resultText,
|
|
@@ -18704,7 +18760,7 @@ void (async () => {
|
|
|
18704
18760
|
// suppresses stale-after-restart delivery (a 4-h-old
|
|
18705
18761
|
// "still working (5m)" would be a lie). Sweep on handback
|
|
18706
18762
|
// lives in the `onFinish` block just above.
|
|
18707
|
-
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount }) => {
|
|
18763
|
+
onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine }) => {
|
|
18708
18764
|
let fleetChatId = ''
|
|
18709
18765
|
try {
|
|
18710
18766
|
const fleets = progressDriver?.peekAllFleets() ?? []
|
|
@@ -18744,7 +18800,15 @@ void (async () => {
|
|
|
18744
18800
|
nestingEnabled: foregroundNestingEnabled,
|
|
18745
18801
|
replyCalled: turn.replyCalled,
|
|
18746
18802
|
})) return
|
|
18747
|
-
|
|
18803
|
+
// Prefer the tick's own display line: `progressLine` (a
|
|
18804
|
+
// friendly tool-step label) on tool ticks, else the
|
|
18805
|
+
// worker's narrative (`latestSummary`) on text ticks. This
|
|
18806
|
+
// lets a foreground sub-agent that runs tools without
|
|
18807
|
+
// emitting prose still nest its steps under the parent
|
|
18808
|
+
// feed (the foreground blindspot) — mirroring the
|
|
18809
|
+
// main-turn activity feed, which surfaces both tool labels
|
|
18810
|
+
// and prose.
|
|
18811
|
+
const child = (progressLine ?? latestSummary).trim().slice(0, 120)
|
|
18748
18812
|
if (child.length === 0) return
|
|
18749
18813
|
let narrative = turn.foregroundSubAgents.get(agentId)
|
|
18750
18814
|
if (narrative == null) {
|
|
@@ -18796,12 +18860,18 @@ void (async () => {
|
|
|
18796
18860
|
return
|
|
18797
18861
|
}
|
|
18798
18862
|
|
|
18863
|
+
const progressOrigin = resolveSubagentOriginChat(agentId)
|
|
18799
18864
|
const decision = decideSubagentProgress({
|
|
18800
18865
|
disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
|
|
18801
18866
|
isBackground,
|
|
18802
18867
|
// Prefer the conversation the Task was dispatched from over
|
|
18803
18868
|
// the owner DM (see resolveSubagentOriginChat).
|
|
18804
|
-
fleetChatId:
|
|
18869
|
+
fleetChatId: progressOrigin?.chatId || fleetChatId,
|
|
18870
|
+
// Carry the dispatching topic so the progress wake lands in
|
|
18871
|
+
// it (applied only when the origin chat resolved).
|
|
18872
|
+
...(progressOrigin?.threadId != null
|
|
18873
|
+
? { originThreadId: progressOrigin.threadId }
|
|
18874
|
+
: {}),
|
|
18805
18875
|
ownerChatId: loadAccess().allowFrom[0] ?? '',
|
|
18806
18876
|
subagentJsonlId: agentId,
|
|
18807
18877
|
taskDescription: description,
|
|
@@ -18819,10 +18889,11 @@ void (async () => {
|
|
|
18819
18889
|
// model is about to compose an explicit in-voice
|
|
18820
18890
|
// progress line — letting the "— still working (Nm)"
|
|
18821
18891
|
// edit fire in parallel would double-surface the
|
|
18822
|
-
// signal.
|
|
18823
|
-
// (
|
|
18892
|
+
// signal. Key the clear on the topic the envelope lands
|
|
18893
|
+
// in (origin thread) so the right lane is yielded in a
|
|
18894
|
+
// supergroup; chat-level for DM-shaped agents.
|
|
18824
18895
|
pendingProgress.clearPending(
|
|
18825
|
-
statusKey(decision.chatId,
|
|
18896
|
+
statusKey(decision.chatId, progressOrigin?.threadId),
|
|
18826
18897
|
'progress',
|
|
18827
18898
|
)
|
|
18828
18899
|
process.stderr.write(
|