switchroom 0.14.57 → 0.14.59

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.
@@ -23874,7 +23874,7 @@ var init_schema = __esm(() => {
23874
23874
  tier: GoogleWorkspaceTierSchema.optional().describe("RFC G Phase 1: which upstream MCP tier to expose. " + "core (default) = ~16 tools (Drive+Docs+Sheets+Calendar). " + "extended = ~40 tools (+Slides, Forms, Tasks, Chat). " + "complete = ~60+ tools (+Gmail; not recommended yet \u2014 see RFC G \u00a75).")
23875
23875
  }).optional();
23876
23876
  MicrosoftWorkspaceConfigSchema = exports_external.object({
23877
- microsoft_client_id: exports_external.string().min(1).describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id')."),
23877
+ microsoft_client_id: exports_external.string().min(1).optional().describe("Microsoft OAuth application (client) ID from Entra portal " + "(literal string or vault reference e.g. " + "'vault:microsoft-oauth-client-id'). OPTIONAL \u2014 omit it to use " + "switchroom's shipped default Microsoft app (zero-config). " + "Set it only to bring your own Entra app (BYO)."),
23878
23878
  microsoft_client_secret: exports_external.string().min(1).optional().describe("Microsoft OAuth client secret. Optional \u2014 public-client apps " + "(Mobile + Desktop platform with 'Allow public client flows' " + "enabled) work without a secret; confidential clients pass " + "one. Either literal or vault reference e.g. " + "'vault:microsoft-oauth-client-secret'."),
23879
23879
  authority: exports_external.string().url().optional().describe("Microsoft authority endpoint. Defaults to " + "'https://login.microsoftonline.com/common' which accepts both " + "personal MSA and work/school tenants. Override only for " + "single-tenant deployments."),
23880
23880
  org_mode: exports_external.boolean().optional().describe("Opt-in to Teams + SharePoint surfaces (RFC \u00a76.4). When true, " + "the v1 scope set adds Sites.ReadWrite.All AND the launcher " + "spawns softeria with --org-mode. Defaults to false \u2014 personal " + "MSA + standard work surfaces only. Flipping for an existing " + "consented account requires re-running 'auth microsoft account " + "add --replace' to consent the additional scope.")
@@ -32729,7 +32729,7 @@ var MIRROR_MAX_LINES = 6;
32729
32729
  function escapeFeedHtml(s) {
32730
32730
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
32731
32731
  }
32732
- function renderActivityFeed(lines, final = false) {
32732
+ function renderActivityFeed(lines, final = false, liveSuffix = "") {
32733
32733
  if (lines.length === 0)
32734
32734
  return null;
32735
32735
  const shown = lines.slice(-MIRROR_MAX_LINES);
@@ -32740,7 +32740,7 @@ function renderActivityFeed(lines, final = false) {
32740
32740
  const lastIdx = shown.length - 1;
32741
32741
  shown.forEach((l, i) => {
32742
32742
  const esc = escapeFeedHtml(l);
32743
- out.push(i === lastIdx && !final ? `<b>\u2192 ${esc}</b>` : `<i>\u2713 ${esc}</i>`);
32743
+ out.push(i === lastIdx && !final ? `<b>\u2192 ${esc}${liveSuffix}</b>` : `<i>\u2713 ${esc}</i>`);
32744
32744
  });
32745
32745
  return out.join(`
32746
32746
  `);
@@ -32748,10 +32748,10 @@ function renderActivityFeed(lines, final = false) {
32748
32748
  var NESTED_MAX_LINES = 4;
32749
32749
  var NESTED_LINE_MAX = 90;
32750
32750
  var NESTED_PREFIX = " \u21b3 ";
32751
- function renderActivityFeedWithNested(lines, childLines, final = false) {
32751
+ function renderActivityFeedWithNested(lines, childLines, final = false, liveSuffix = "") {
32752
32752
  const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
32753
32753
  if (children.length === 0)
32754
- return renderActivityFeed(lines, final);
32754
+ return renderActivityFeed(lines, final, liveSuffix);
32755
32755
  const out = [];
32756
32756
  const shownParent = lines.slice(-MIRROR_MAX_LINES);
32757
32757
  const hiddenParent = lines.length - shownParent.length;
@@ -32767,7 +32767,7 @@ function renderActivityFeedWithNested(lines, childLines, final = false) {
32767
32767
  shownChild.forEach((l, i) => {
32768
32768
  const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "\u2026" : l;
32769
32769
  const esc = escapeFeedHtml(t);
32770
- out.push(i === lastChildIdx && !final ? `${NESTED_PREFIX}<b>\u2192 ${esc}</b>` : `${NESTED_PREFIX}<i>${esc}</i>`);
32770
+ out.push(i === lastChildIdx && !final ? `${NESTED_PREFIX}<b>\u2192 ${esc}${liveSuffix}</b>` : `${NESTED_PREFIX}<i>${esc}</i>`);
32771
32771
  });
32772
32772
  return out.length > 0 ? out.join(`
32773
32773
  `) : null;
@@ -39372,6 +39372,13 @@ function isFinalAnswerReply(input) {
39372
39372
  return true;
39373
39373
  return false;
39374
39374
  }
39375
+ function isSubstantiveFinalReply(input) {
39376
+ if (input.done === true)
39377
+ return true;
39378
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS)
39379
+ return true;
39380
+ return false;
39381
+ }
39375
39382
 
39376
39383
  // turn-flush-safety.ts
39377
39384
  var SILENT_MARKERS = new Set(["NO_REPLY", "HEARTBEAT_OK"]);
@@ -47125,6 +47132,92 @@ function createPendingInboundBuffer(opts = {}) {
47125
47132
  };
47126
47133
  }
47127
47134
 
47135
+ // gateway/obligation-ledger.ts
47136
+ class ObligationLedger {
47137
+ maxRepresents;
47138
+ open = new Map;
47139
+ constructor(maxRepresents = 2) {
47140
+ this.maxRepresents = maxRepresents;
47141
+ }
47142
+ openIfAbsent(input) {
47143
+ if (this.open.has(input.originTurnId))
47144
+ return false;
47145
+ this.open.set(input.originTurnId, { ...input, representCount: 0 });
47146
+ return true;
47147
+ }
47148
+ close(originTurnId) {
47149
+ if (originTurnId == null)
47150
+ return false;
47151
+ return this.open.delete(originTurnId);
47152
+ }
47153
+ isOpen(originTurnId) {
47154
+ return this.open.has(originTurnId);
47155
+ }
47156
+ hasOpen() {
47157
+ return this.open.size > 0;
47158
+ }
47159
+ size() {
47160
+ return this.open.size;
47161
+ }
47162
+ list() {
47163
+ return [...this.open.values()].sort((a, b) => a.openedAt - b.openedAt);
47164
+ }
47165
+ oldest() {
47166
+ let best;
47167
+ for (const o of this.open.values()) {
47168
+ if (best === undefined || o.openedAt < best.openedAt)
47169
+ best = o;
47170
+ }
47171
+ return best;
47172
+ }
47173
+ decideAtIdle() {
47174
+ const o = this.oldest();
47175
+ if (o === undefined)
47176
+ return { action: "none" };
47177
+ if (o.representCount >= this.maxRepresents)
47178
+ return { action: "escalate", obligation: o };
47179
+ return { action: "represent", obligation: o };
47180
+ }
47181
+ resolveCloseTarget(echoedTurnId, liveTurnId) {
47182
+ if (echoedTurnId != null)
47183
+ return echoedTurnId;
47184
+ if (liveTurnId != null && this.open.size === 1 && this.open.has(liveTurnId))
47185
+ return liveTurnId;
47186
+ return null;
47187
+ }
47188
+ markRepresented(originTurnId) {
47189
+ const o = this.open.get(originTurnId);
47190
+ if (o === undefined)
47191
+ return 0;
47192
+ o.representCount += 1;
47193
+ return o.representCount;
47194
+ }
47195
+ }
47196
+ var REPRESENT_PREVIEW_MAX = 200;
47197
+ function buildObligationRepresentInbound(o, now) {
47198
+ const preview = o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + "\u2026" : o.text;
47199
+ const topicClause = o.threadId != null ? " in this topic" : "";
47200
+ return {
47201
+ type: "inbound",
47202
+ chatId: o.chatId,
47203
+ ...o.threadId != null ? { threadId: o.threadId } : {},
47204
+ messageId: o.messageId,
47205
+ user: "switchroom",
47206
+ userId: 0,
47207
+ ts: now,
47208
+ text: `You have an earlier message${topicClause} that you started but never actually ` + `answered (you may have set it aside mid-work): "${preview}". Answer it now via the ` + `reply tool \u2014 deliver the real answer, don't just acknowledge it. If you've lost the ` + `surrounding context, call get_recent_messages for this chat${topicClause} first. ` + `That quoted text may be only the first ~200 characters of the original.`,
47209
+ meta: {
47210
+ source: "obligation_represent",
47211
+ origin_turn_id: o.originTurnId,
47212
+ represent_count: String(o.representCount + 1)
47213
+ }
47214
+ };
47215
+ }
47216
+ function obligationEscalationText(o) {
47217
+ const preview = o.text.length > REPRESENT_PREVIEW_MAX ? o.text.slice(0, REPRESENT_PREVIEW_MAX - 1) + "\u2026" : o.text;
47218
+ return `\u26a0\ufe0f I may have missed an earlier message and I'm not sure I answered it: ` + `"${preview}". If you still need it, please re-send.`;
47219
+ }
47220
+
47128
47221
  // gateway/inbound-spool.ts
47129
47222
  function spoolId(msg) {
47130
47223
  if (msg.meta?.source === "subagent_handback" && typeof msg.meta?.subagent_jsonl_id === "string" && msg.meta.subagent_jsonl_id.length > 0) {
@@ -47366,6 +47459,28 @@ function shouldArmNoReplyDrain(input) {
47366
47459
  return input.bufferedDepth > 0;
47367
47460
  }
47368
47461
 
47462
+ // gateway/feed-reopen-gate.ts
47463
+ function shouldReopenFeedAfterAck(input) {
47464
+ if (!input.finalAnswerDelivered)
47465
+ return false;
47466
+ if (input.finalAnswerSubstantive)
47467
+ return false;
47468
+ return input.enabled === true;
47469
+ }
47470
+ function decideFeedReopen(input) {
47471
+ if (!shouldReopenFeedAfterAck(input)) {
47472
+ return { dropLabel: true };
47473
+ }
47474
+ return {
47475
+ dropLabel: false,
47476
+ reset: {
47477
+ finalAnswerDelivered: false,
47478
+ activityMessageId: null,
47479
+ activityLastSentRender: null
47480
+ }
47481
+ };
47482
+ }
47483
+
47369
47484
  // gateway/answer-thread-resolve.ts
47370
47485
  function resolveAnswerThreadId(input) {
47371
47486
  if (input.explicitThreadId != null)
@@ -52195,10 +52310,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52195
52310
  }
52196
52311
 
52197
52312
  // ../src/build-info.ts
52198
- var VERSION = "0.14.57";
52199
- var COMMIT_SHA = "ddb0b353";
52200
- var COMMIT_DATE = "2026-06-03T22:37:37Z";
52201
- var LATEST_PR = 2140;
52313
+ var VERSION = "0.14.59";
52314
+ var COMMIT_SHA = "178c6d14";
52315
+ var COMMIT_DATE = "2026-06-04T07:31:45Z";
52316
+ var LATEST_PR = 2146;
52202
52317
  var COMMITS_AHEAD_OF_TAG = 0;
52203
52318
 
52204
52319
  // gateway/boot-version.ts
@@ -53396,6 +53511,10 @@ var _deliveryTimeoutParsed = _deliveryTimeoutRaw != null && _deliveryTimeoutRaw
53396
53511
  var DELIVERY_CONFIRM_TIMEOUT_MS = Number.isFinite(_deliveryTimeoutParsed) && _deliveryTimeoutParsed > 0 ? _deliveryTimeoutParsed : 15000;
53397
53512
  var DELIVERY_CONFIRM_SWEEP_MS = 5000;
53398
53513
  var deliveryQueue = createDeliveryQueue();
53514
+ var OBLIGATION_LEDGER_ENABLED = process.env.SWITCHROOM_OBLIGATION_LEDGER === "1";
53515
+ var OBLIGATION_REPRESENT_MAX = 2;
53516
+ var OBLIGATION_SWEEP_MS = 5000;
53517
+ var obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX);
53399
53518
  var SERIALIZE_UNTIL_REPLIED_ENABLED = process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== "0";
53400
53519
  var _noReplyDrainRaw = process.env.SWITCHROOM_SERIALIZE_NOREPLY_DRAIN_MS;
53401
53520
  var _noReplyDrainParsed = _noReplyDrainRaw != null && _noReplyDrainRaw !== "" ? Number(_noReplyDrainRaw) : 2500;
@@ -53403,6 +53522,17 @@ var SERIALIZE_NOREPLY_DRAIN_MS = Number.isFinite(_noReplyDrainParsed) && _noRepl
53403
53522
  var TURN_ORIGIN_ROUTING_ENABLED = process.env.SWITCHROOM_TURN_ORIGIN_ROUTING !== "0";
53404
53523
  var TOPIC_FRAMING_ENABLED = process.env.SWITCHROOM_TOPIC_FRAMING !== "0";
53405
53524
  var QUEUED_STATUS_UX_ENABLED = process.env.SWITCHROOM_QUEUED_STATUS_UX !== "0";
53525
+ var FEED_REOPEN_AFTER_ACK_ENABLED = process.env.SWITCHROOM_FEED_REOPEN_AFTER_ACK !== "0";
53526
+ var FEED_HEARTBEAT_ENABLED = process.env.SWITCHROOM_FEED_HEARTBEAT !== "0";
53527
+ var FEED_HEARTBEAT_TICK_MS = 6000;
53528
+ var FEED_HEARTBEAT_MIN_STALE_MS = 6000;
53529
+ function formatFeedElapsed(ms) {
53530
+ const s = Math.floor(ms / 1000);
53531
+ if (s < 60)
53532
+ return `${s}s`;
53533
+ const m = Math.floor(s / 60);
53534
+ return `${m}m${(s % 60).toString().padStart(2, "0")}s`;
53535
+ }
53406
53536
  function turnInFlightForGate() {
53407
53537
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
53408
53538
  }
@@ -53443,6 +53573,37 @@ function findTurnByOriginId(originTurnId) {
53443
53573
  return currentTurn;
53444
53574
  return recentTurnsById.get(originTurnId) ?? null;
53445
53575
  }
53576
+ function closeObligationOnSubstantiveReply(args, liveTurn) {
53577
+ if (!OBLIGATION_LEDGER_ENABLED)
53578
+ return;
53579
+ const echoed = findTurnByOriginId(args.origin_turn_id);
53580
+ const target = obligationLedger.resolveCloseTarget(echoed?.turnId, liveTurn?.turnId);
53581
+ if (target != null)
53582
+ obligationLedger.close(target);
53583
+ }
53584
+ function openObligationFromInbound(inboundMsg, gate) {
53585
+ if (!OBLIGATION_LEDGER_ENABLED)
53586
+ return;
53587
+ if (!shouldTrackDelivery({
53588
+ isSteering: gate.isSteering,
53589
+ isInterrupt: gate.isInterrupt,
53590
+ hasSource: inboundMsg.meta?.source != null,
53591
+ effectiveText: gate.effectiveText
53592
+ })) {
53593
+ return;
53594
+ }
53595
+ const oid = deriveTurnId(inboundMsg.chatId, inboundMsg.threadId, inboundMsg.messageId);
53596
+ if (oid == null)
53597
+ return;
53598
+ obligationLedger.openIfAbsent({
53599
+ originTurnId: oid,
53600
+ chatId: inboundMsg.chatId,
53601
+ threadId: inboundMsg.threadId,
53602
+ messageId: inboundMsg.messageId,
53603
+ text: inboundMsg.text ?? "",
53604
+ openedAt: Date.now()
53605
+ });
53606
+ }
53446
53607
  function postQueuedStatus(chatId, bufferedThread, inFlightThread) {
53447
53608
  if (!QUEUED_STATUS_UX_ENABLED)
53448
53609
  return;
@@ -54718,6 +54879,40 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
54718
54879
  }
54719
54880
  });
54720
54881
  var pendingInboundBuffer = createPendingInboundBuffer({ spool: inboundSpool });
54882
+ function obligationSweep() {
54883
+ if (!OBLIGATION_LEDGER_ENABLED)
54884
+ return;
54885
+ if (!obligationLedger.hasOpen())
54886
+ return;
54887
+ if (turnInFlightForGate())
54888
+ return;
54889
+ const agent = process.env.SWITCHROOM_AGENT_NAME ?? "";
54890
+ if (pendingInboundBuffer.depth(agent) > 0)
54891
+ return;
54892
+ const decision = obligationLedger.decideAtIdle();
54893
+ const o = decision.obligation;
54894
+ if (decision.action === "none" || o == null)
54895
+ return;
54896
+ if (decision.action === "represent") {
54897
+ pendingInboundBuffer.push(agent, buildObligationRepresentInbound(o, Date.now()));
54898
+ const attempt = obligationLedger.markRepresented(o.originTurnId);
54899
+ process.stderr.write(`telegram gateway: obligation re-presented origin=${o.originTurnId} attempt=${attempt}/${OBLIGATION_REPRESENT_MAX}
54900
+ `);
54901
+ return;
54902
+ }
54903
+ obligationLedger.close(o.originTurnId);
54904
+ process.stderr.write(`telegram gateway: obligation escalated (exhausted ${OBLIGATION_REPRESENT_MAX} re-presents) origin=${o.originTurnId}
54905
+ `);
54906
+ robustApiCall(() => bot.api.sendMessage(o.chatId, obligationEscalationText(o), {
54907
+ ...o.threadId != null ? { message_thread_id: o.threadId } : {}
54908
+ }), { chat_id: o.chatId, ...o.threadId != null ? { threadId: o.threadId } : {}, verb: "obligation.escalate" }).catch((err) => {
54909
+ process.stderr.write(`telegram gateway: obligation escalation send failed: ${err}
54910
+ `);
54911
+ });
54912
+ }
54913
+ if (!STATIC && OBLIGATION_LEDGER_ENABLED) {
54914
+ setInterval(obligationSweep, OBLIGATION_SWEEP_MS).unref();
54915
+ }
54721
54916
  if (bootResumeInbound != null) {
54722
54917
  if (inboundSpool != null) {
54723
54918
  inboundSpool.put(bootResumeInbound.agent, bootResumeInbound.msg);
@@ -55690,6 +55885,12 @@ ${url}`;
55690
55885
  disableNotification
55691
55886
  })) {
55692
55887
  turn2.finalAnswerDelivered = true;
55888
+ turn2.finalAnswerSubstantive = isSubstantiveFinalReply({
55889
+ text: decision.mergedText,
55890
+ disableNotification
55891
+ });
55892
+ if (turn2.finalAnswerSubstantive)
55893
+ closeObligationOnSubstantiveReply(args, turn2);
55693
55894
  }
55694
55895
  outboundDedup.record(chat_id, threadId, decision.mergedText, Date.now(), turn2?.registryKey ?? null);
55695
55896
  silentAnchorEditDone = true;
@@ -55889,7 +56090,10 @@ ${url}`;
55889
56090
  noteSignal(statusKey(chat_id, threadId), Date.now());
55890
56091
  if (turn != null && isFinalAnswerReply({ text: rawText, disableNotification })) {
55891
56092
  turn.finalAnswerDelivered = true;
56093
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({ text: rawText, disableNotification });
55892
56094
  finalizeStatusReaction(chat_id, threadId, "done");
56095
+ if (turn.finalAnswerSubstantive)
56096
+ closeObligationOnSubstantiveReply(args, turn);
55893
56097
  }
55894
56098
  releaseTurnBufferGate(statusKey(chat_id, threadId), turn ?? undefined);
55895
56099
  if (turn?.finalAnswerDelivered === true) {
@@ -56055,6 +56259,13 @@ async function executeStreamReply(args) {
56055
56259
  done: args.done === true
56056
56260
  })) {
56057
56261
  turn.finalAnswerDelivered = true;
56262
+ turn.finalAnswerSubstantive = isSubstantiveFinalReply({
56263
+ text: args.text ?? "",
56264
+ disableNotification: args.disable_notification === true,
56265
+ done: args.done === true
56266
+ });
56267
+ if (turn.finalAnswerSubstantive)
56268
+ closeObligationOnSubstantiveReply(args, turn);
56058
56269
  const streamThreadIdForClear = args.message_thread_id != null ? Number(args.message_thread_id) : undefined;
56059
56270
  clearSilentEndState(statusKey(streamChatId, streamThreadIdForClear));
56060
56271
  }
@@ -57046,12 +57257,12 @@ function closeProgressLane(chatId, threadId) {
57046
57257
  }
57047
57258
  }
57048
57259
  var FOREGROUND_SUBAGENT_ACCUM_MAX = 12;
57049
- function composeTurnActivity(turn, final = false) {
57260
+ function composeTurnActivity(turn, final = false, liveSuffix = "") {
57050
57261
  const childLines = [];
57051
57262
  for (const narrative of turn.foregroundSubAgents.values()) {
57052
57263
  childLines.push(...narrative);
57053
57264
  }
57054
- return renderActivityFeedWithNested(turn.mirrorLines, childLines, final);
57265
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines, final, liveSuffix);
57055
57266
  }
57056
57267
  async function drainActivitySummary(turn) {
57057
57268
  try {
@@ -57090,6 +57301,30 @@ async function drainActivitySummary(turn) {
57090
57301
  turn.activityInFlight = null;
57091
57302
  }
57092
57303
  }
57304
+ function feedHeartbeatTick() {
57305
+ const turn = currentTurn;
57306
+ if (turn == null)
57307
+ return;
57308
+ if (turn.activityMessageId == null)
57309
+ return;
57310
+ if (turn.finalAnswerDelivered)
57311
+ return;
57312
+ if (turn.lastToolLabelAt == null)
57313
+ return;
57314
+ const elapsed = Date.now() - turn.lastToolLabelAt;
57315
+ if (elapsed < FEED_HEARTBEAT_MIN_STALE_MS)
57316
+ return;
57317
+ const rendered = composeTurnActivity(turn, false, ` \xB7 ${formatFeedElapsed(elapsed)}`);
57318
+ if (rendered == null)
57319
+ return;
57320
+ turn.activityPendingRender = rendered;
57321
+ if (turn.activityInFlight == null) {
57322
+ turn.activityInFlight = drainActivitySummary(turn);
57323
+ }
57324
+ }
57325
+ if (!STATIC && FEED_HEARTBEAT_ENABLED) {
57326
+ setInterval(feedHeartbeatTick, FEED_HEARTBEAT_TICK_MS).unref();
57327
+ }
57093
57328
  function clearActivitySummary(turn, finalHtmlOverride) {
57094
57329
  const chat = turn.sessionChatId;
57095
57330
  const thread = turn.sessionThreadId;
@@ -57148,6 +57383,7 @@ function handleSessionEvent(ev) {
57148
57383
  gatewayReceiveAt: startedAt,
57149
57384
  replyCalled: false,
57150
57385
  finalAnswerDelivered: false,
57386
+ finalAnswerSubstantive: false,
57151
57387
  firstPingAt: null,
57152
57388
  silentAnchorMessageId: null,
57153
57389
  silentAnchorText: "",
@@ -57259,10 +57495,21 @@ function handleSessionEvent(ev) {
57259
57495
  return;
57260
57496
  if (isTelegramSurfaceTool(ev.toolName))
57261
57497
  return;
57262
- if (turn.finalAnswerDelivered)
57263
- return;
57498
+ if (turn.finalAnswerDelivered) {
57499
+ const reopen = decideFeedReopen({
57500
+ finalAnswerDelivered: turn.finalAnswerDelivered,
57501
+ finalAnswerSubstantive: turn.finalAnswerSubstantive,
57502
+ enabled: FEED_REOPEN_AFTER_ACK_ENABLED
57503
+ });
57504
+ if (reopen.dropLabel)
57505
+ return;
57506
+ turn.finalAnswerDelivered = reopen.reset.finalAnswerDelivered;
57507
+ turn.activityMessageId = reopen.reset.activityMessageId;
57508
+ turn.activityLastSentRender = reopen.reset.activityLastSentRender;
57509
+ }
57264
57510
  const rendered = appendActivityLabel(turn.mirrorLines, ev.label);
57265
57511
  if (rendered != null) {
57512
+ turn.lastToolLabelAt = Date.now();
57266
57513
  turn.activityPendingRender = composeTurnActivity(turn) ?? rendered;
57267
57514
  if (turn.activityInFlight == null) {
57268
57515
  turn.activityInFlight = drainActivitySummary(turn);
@@ -57412,6 +57659,7 @@ function handleSessionEvent(ev) {
57412
57659
  turn.answerStream = null;
57413
57660
  streamFinalizedAsAnswer = true;
57414
57661
  turn.finalAnswerDelivered = true;
57662
+ turn.finalAnswerSubstantive = true;
57415
57663
  const oldStreamedMsgId = streamedMsgId;
57416
57664
  (async () => {
57417
57665
  let materializedId;
@@ -57538,6 +57786,7 @@ function handleSessionEvent(ev) {
57538
57786
  }
57539
57787
  }
57540
57788
  turn.finalAnswerDelivered = true;
57789
+ turn.finalAnswerSubstantive = true;
57541
57790
  const cardTakeover = progressDriver?.takeOverCard({
57542
57791
  chatId: backstopChatId,
57543
57792
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
@@ -58585,6 +58834,11 @@ ${preBlock(write.output)}`;
58585
58834
  }
58586
58835
  return;
58587
58836
  }
58837
+ openObligationFromInbound(inboundMsg, {
58838
+ isSteering,
58839
+ isInterrupt: interrupt.isInterrupt,
58840
+ effectiveText
58841
+ });
58588
58842
  if (decideInboundDelivery({
58589
58843
  turnInFlight: turnInFlightAtReceipt,
58590
58844
  isSteering,
@@ -81,3 +81,37 @@ export function isFinalAnswerReply(input: FinalAnswerReplyInput): boolean {
81
81
  if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
82
82
  return false
83
83
  }
84
+
85
+ /**
86
+ * Pure predicate: was this reply a *substantive* final answer (as opposed
87
+ * to a reply that is only "final" because it pinged)? `true` if EITHER:
88
+ *
89
+ * - `done === true` — a `stream_reply` terminal call closing the stream.
90
+ * - `text.length >= FINAL_ANSWER_MIN_CHARS` — a substantive-length answer.
91
+ *
92
+ * This is `isFinalAnswerReply` MINUS the notification-only path. The
93
+ * distinction matters for the feed-reopen-after-ack gate
94
+ * (`feed-reopen-gate.ts`): a *short pinging* reply ("on it, checking
95
+ * Brevo…") is classified final by `isFinalAnswerReply` (because it pings)
96
+ * yet is NOT substantive — it is an interim ACK. Only such an ack should
97
+ * cause the live activity feed to re-open when post-ack tool work arrives.
98
+ *
99
+ * A genuine final answer (long, or a stream `done: true`) followed by
100
+ * routine post-answer housekeeping (a memory write / TodoWrite / Bash —
101
+ * none of which are surface tools, so they reach the tool_label handler)
102
+ * must NOT re-open the feed and must NOT reset `finalAnswerDelivered`,
103
+ * otherwise the silent-end re-prompt would spuriously fire and the agent
104
+ * would re-deliver a duplicate / garbled answer.
105
+ *
106
+ * Residual: a reply that is genuinely the final answer yet is BOTH short
107
+ * (<200 chars) AND pinging (e.g. "Done!") is indistinguishable here from
108
+ * an ack, so post-answer housekeeping after it still re-opens the feed.
109
+ * That is much rarer than the housekeeping-after-long-answer case this
110
+ * predicate protects, and is kill-switchable via
111
+ * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0`.
112
+ */
113
+ export function isSubstantiveFinalReply(input: FinalAnswerReplyInput): boolean {
114
+ if (input.done === true) return true
115
+ if (input.text.length >= FINAL_ANSWER_MIN_CHARS) return true
116
+ return false
117
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Feed-reopen-after-ack gate (ack-first live-activity visibility).
3
+ *
4
+ * Pure decision: a `tool_label` arrived (the model is calling a tool, i.e.
5
+ * still WORKING) for a turn that has already been classified as having
6
+ * delivered its final answer. Should the gateway *re-open* the live
7
+ * activity feed for the post-ack work?
8
+ *
9
+ * ## The bug this closes
10
+ *
11
+ * In a forum supergroup one agent owns the whole supergroup — a single
12
+ * sequential `claude` CLI with a singleton `currentTurn`. When the model
13
+ * ACKS FIRST ("on it, checking Brevo…") and then does the actual work,
14
+ * that ack reply is classified as the *final answer* by
15
+ * `isFinalAnswerReply` (final-answer-detect.ts) whenever it pings
16
+ * (`!disable_notification`) OR is ≥200 chars — both common for a natural
17
+ * human-feel ack. That sets `turn.finalAnswerDelivered = true`, and the
18
+ * `tool_label` handler's `if (turn.finalAnswerDelivered) return` then
19
+ * drops EVERY subsequent tool label → the live feed goes dark for the
20
+ * real work. The agent looks silent after "On it".
21
+ *
22
+ * ## The decision
23
+ *
24
+ * A new tool label after `finalAnswerDelivered` means the earlier "final"
25
+ * reply MIGHT have been an interim ACK — the turn has NOT delivered its
26
+ * final answer if it is still doing tool work. So reclassify: re-open the
27
+ * feed. The caller then resets `turn.finalAnswerDelivered = false` and
28
+ * `turn.activityMessageId = null` (so a FRESH feed message opens below the
29
+ * ack) and proceeds with the normal append + drain. When the model later
30
+ * sends its REAL final answer, `executeReply` / `stream_reply` re-set
31
+ * `finalAnswerDelivered = true` via `isFinalAnswerReply` and the feed gates
32
+ * off correctly again.
33
+ *
34
+ * ## ACK-ONLY refinement
35
+ *
36
+ * `finalAnswerDelivered` latches true for BOTH a short pinging ack AND a
37
+ * substantive final answer — `isFinalAnswerReply` treats any pinging reply
38
+ * as "final". So reopening unconditionally is HARMFUL after a *genuine*
39
+ * final answer: routine post-answer housekeeping (a memory write /
40
+ * TodoWrite / Bash — none of these are surface tools, so they reach the
41
+ * tool_label handler) fires a tool label → an unconditional reopen would
42
+ * reset `finalAnswerDelivered=false` → the turn-end silent-end re-prompt
43
+ * (`if (turn.finalAnswerDelivered === false)`, NOT gated on zero-outbound)
44
+ * would FIRE → the agent re-delivers a DUPLICATE / garbled answer. Agents
45
+ * routinely write memory after answering, so this would be frequent.
46
+ *
47
+ * The fix: reopen ONLY when the prior reply that set `finalAnswerDelivered`
48
+ * was a SHORT ACK, not a substantive answer. The caller tracks this on the
49
+ * turn as `finalAnswerSubstantive` (set via `isSubstantiveFinalReply` at
50
+ * every site that sets `finalAnswerDelivered = true`). Reopen iff
51
+ * `finalAnswerDelivered && !finalAnswerSubstantive`. When the prior final
52
+ * was substantive, drop the label (legacy gate) — no reopen, no reset — so
53
+ * the silent-end re-prompt and the #2137 drain both see the genuine final
54
+ * correctly.
55
+ *
56
+ * ## Interactions (the reset is correct for all three consumers)
57
+ *
58
+ * 1. #2137 deliver-before-drain gate (`mayDrainBufferedInbound`): reads the
59
+ * ending turn's `finalAnswerDelivered` at turn-end. With the reset, an
60
+ * ack-first turn that is still working keeps it false → the next topic
61
+ * is correctly HELD (no mid-work cross-topic bleed); the bounded
62
+ * no-reply drain timer (~2.5s) still releases the queue if the turn
63
+ * truly ends without a final answer.
64
+ * 2. silent-end re-prompt: a turn that acks, works, then ends with NO real
65
+ * final answer keeps `finalAnswerDelivered=false` → the re-prompt fires
66
+ * (correct — the user got only an ack, no answer).
67
+ * 3. the feed gate itself — this module.
68
+ *
69
+ * ## Kill switch
70
+ *
71
+ * `SWITCHROOM_FEED_REOPEN_AFTER_ACK=0` reverts to the legacy behaviour: a
72
+ * tool label after `finalAnswerDelivered` is dropped (`return`), and the
73
+ * post-ack feed stays dark. The kill switch is read by the CALLER, which
74
+ * passes `enabled` here.
75
+ */
76
+
77
+ export interface FeedReopenInput {
78
+ /** Whether the turn has already been classified as having delivered its
79
+ * final answer (`turn.finalAnswerDelivered`). On an ack-first turn this
80
+ * is set true by the ack reply (it pinged or was ≥200 chars), even
81
+ * though the model is still working. */
82
+ finalAnswerDelivered: boolean
83
+ /** Whether the reply that set `finalAnswerDelivered` was a *substantive*
84
+ * final answer (stream `done`, or ≥200 chars) as opposed to a short
85
+ * pinging interim ACK (`turn.finalAnswerSubstantive`, set via
86
+ * `isSubstantiveFinalReply`). Only a short ACK should re-open the feed:
87
+ * reopening after a genuine final answer + post-answer housekeeping
88
+ * would spuriously trip the silent-end re-prompt → duplicate answer. */
89
+ finalAnswerSubstantive: boolean
90
+ /** Kill-switch state. When false the reopen behaviour is OFF and a tool
91
+ * label after `finalAnswerDelivered` is dropped (legacy). */
92
+ enabled: boolean
93
+ }
94
+
95
+ /**
96
+ * Pure. Given a tool label has just arrived (the model is calling a tool,
97
+ * so it is still working), returns true when the live activity feed should
98
+ * be RE-OPENED for the post-ack work.
99
+ *
100
+ * - !finalAnswerDelivered → false: the feed was never gated off; the normal
101
+ * append/drain path applies (no reopen needed).
102
+ * - finalAnswerDelivered && finalAnswerSubstantive → false: the prior final
103
+ * was a genuine answer (not an ack). Post-answer housekeeping tool work
104
+ * must NOT reopen — keep the legacy gate so the silent-end re-prompt and
105
+ * the #2137 drain see the delivered final correctly.
106
+ * - finalAnswerDelivered && !enabled (kill switch off) → false: legacy
107
+ * behaviour, the label is dropped by the caller.
108
+ * - finalAnswerDelivered && !finalAnswerSubstantive && enabled → true: the
109
+ * "final" reply was a short interim ack; re-open the feed.
110
+ */
111
+ export function shouldReopenFeedAfterAck(input: FeedReopenInput): boolean {
112
+ if (!input.finalAnswerDelivered) return false
113
+ if (input.finalAnswerSubstantive) return false
114
+ return input.enabled === true
115
+ }
116
+
117
+ /** The feed-state fields the caller mutates on reopen. */
118
+ export interface FeedReopenState {
119
+ finalAnswerDelivered: boolean
120
+ activityMessageId: number | null
121
+ activityLastSentRender: string | null
122
+ }
123
+
124
+ /** The branch outcome the tool_label handler takes for a finalAnswer-
125
+ * delivered turn: either drop the label (legacy `return`) or reopen the
126
+ * feed with the given reset state. */
127
+ export interface FeedReopenOutcome {
128
+ /** True → the handler returns early (legacy: label dropped, feed dark). */
129
+ dropLabel: boolean
130
+ /** When dropLabel is false, the new feed-state fields to write on `turn`
131
+ * before the normal append/drain proceeds. */
132
+ reset?: FeedReopenState
133
+ }
134
+
135
+ /**
136
+ * Pure. The complete tool_label decision for a turn already marked
137
+ * finalAnswerDelivered. Mirrors exactly what the gateway handler does:
138
+ * - reopen disabled / substantive final / not applicable → drop the label
139
+ * (legacy `return`); the genuine final answer's gate is preserved.
140
+ * - reopen → reclassify the interim ack: finalAnswerDelivered back to
141
+ * false (the turn has NOT delivered its final answer while still doing
142
+ * tool work), activityMessageId cleared so a FRESH feed message opens
143
+ * below the ack, and activityLastSentRender cleared so the drain loop's
144
+ * `pending !== lastSent` guard never mistakes the fresh render for an
145
+ * already-sent one.
146
+ *
147
+ * Returning the deltas (rather than mutating) keeps the decision unit-
148
+ * testable; the handler applies them to the live `turn` atom.
149
+ */
150
+ export function decideFeedReopen(input: FeedReopenInput): FeedReopenOutcome {
151
+ if (!shouldReopenFeedAfterAck(input)) {
152
+ return { dropLabel: true }
153
+ }
154
+ return {
155
+ dropLabel: false,
156
+ reset: {
157
+ finalAnswerDelivered: false,
158
+ activityMessageId: null,
159
+ activityLastSentRender: null,
160
+ },
161
+ }
162
+ }