switchroom 0.16.14 → 0.16.16

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.
@@ -34032,6 +34032,40 @@ function createTypingWrapper(deps) {
34032
34032
  };
34033
34033
  }
34034
34034
 
34035
+ // gateway/turn-typing-loop.ts
34036
+ function createTurnTypingLoop(deps) {
34037
+ const refreshMs = deps.refreshMs ?? 4000;
34038
+ const intervals = new Map;
34039
+ function stop(chatId, threadId = null) {
34040
+ const key = deps.chatKey(chatId, threadId);
34041
+ const iv = intervals.get(key);
34042
+ if (iv != null) {
34043
+ clearInterval(iv);
34044
+ intervals.delete(key);
34045
+ }
34046
+ }
34047
+ function start(chatId, threadId = null) {
34048
+ stop(chatId, threadId);
34049
+ const key = deps.chatKey(chatId, threadId);
34050
+ const send = () => deps.sendChatAction(chatId, threadId);
34051
+ send();
34052
+ const iv = setInterval(send, refreshMs);
34053
+ iv.unref?.();
34054
+ intervals.set(key, iv);
34055
+ }
34056
+ function stopAll() {
34057
+ for (const iv of [...intervals.values()])
34058
+ clearInterval(iv);
34059
+ intervals.clear();
34060
+ }
34061
+ return {
34062
+ start,
34063
+ stop,
34064
+ stopAll,
34065
+ activeCount: () => intervals.size
34066
+ };
34067
+ }
34068
+
34035
34069
  // draft-stream.ts
34036
34070
  var TELEGRAM_MAX_CHARS = 4096;
34037
34071
  var DEFAULT_DM_THROTTLE_MS = 400;
@@ -40362,6 +40396,7 @@ function createAnswerStream(config) {
40362
40396
  replyToMessageId,
40363
40397
  sendMessage,
40364
40398
  editMessageText,
40399
+ renderText,
40365
40400
  onSuperseded,
40366
40401
  log,
40367
40402
  warn,
@@ -40370,6 +40405,7 @@ function createAnswerStream(config) {
40370
40405
  recordDedup,
40371
40406
  recordOutbound
40372
40407
  } = config;
40408
+ const render = (text) => renderText != null ? renderText(text) : text;
40373
40409
  const effectiveThrottle = Math.max(250, throttleMs);
40374
40410
  let streamMsgId;
40375
40411
  let pendingText = null;
@@ -40404,6 +40440,7 @@ function createAnswerStream(config) {
40404
40440
  }
40405
40441
  }
40406
40442
  async function sendOrEditViaMessage(trimmed, gen, prevText) {
40443
+ const rendered = render(trimmed);
40407
40444
  if (typeof streamMsgId === "number") {
40408
40445
  const editParams = {
40409
40446
  parse_mode: "HTML",
@@ -40412,7 +40449,7 @@ function createAnswerStream(config) {
40412
40449
  if (threadId != null)
40413
40450
  editParams.message_thread_id = threadId;
40414
40451
  try {
40415
- await editMessageText(chatId, streamMsgId, trimmed, editParams);
40452
+ await editMessageText(chatId, streamMsgId, rendered, editParams);
40416
40453
  onMetric?.({ kind: "answer_lane_update", chatId, messageId: streamMsgId, charCount: trimmed.length, transport: "edit" });
40417
40454
  } catch (err) {
40418
40455
  const msg = err instanceof Error ? err.message : String(err);
@@ -40437,7 +40474,7 @@ function createAnswerStream(config) {
40437
40474
  sendParams.message_thread_id = threadId;
40438
40475
  if (replyToMessageId != null)
40439
40476
  sendParams.reply_parameters = { message_id: replyToMessageId };
40440
- const sent = await sendMessage(chatId, trimmed, sendParams);
40477
+ const sent = await sendMessage(chatId, rendered, sendParams);
40441
40478
  const sentId = sent?.message_id;
40442
40479
  if (typeof sentId !== "number" || !Number.isFinite(sentId)) {
40443
40480
  warn?.("answer-stream: sendMessage returned no message_id");
@@ -40552,7 +40589,7 @@ function createAnswerStream(config) {
40552
40589
  if (threadId != null)
40553
40590
  sendParams.message_thread_id = threadId;
40554
40591
  try {
40555
- const sent = await sendMessage(chatId, textToSend, sendParams);
40592
+ const sent = await sendMessage(chatId, render(textToSend), sendParams);
40556
40593
  const sentId = sent?.message_id;
40557
40594
  if (typeof sentId === "number" && Number.isFinite(sentId)) {
40558
40595
  streamMsgId = sentId;
@@ -43708,6 +43745,131 @@ function getOpenTags(html) {
43708
43745
  return tagStack;
43709
43746
  }
43710
43747
 
43748
+ // html-sanitize.ts
43749
+ var ALLOWED_TAGS2 = new Set([
43750
+ "b",
43751
+ "strong",
43752
+ "i",
43753
+ "em",
43754
+ "u",
43755
+ "ins",
43756
+ "s",
43757
+ "strike",
43758
+ "del",
43759
+ "a",
43760
+ "code",
43761
+ "pre",
43762
+ "span",
43763
+ "tg-spoiler",
43764
+ "tg-emoji",
43765
+ "blockquote"
43766
+ ]);
43767
+ var ALLOWED_ATTRS2 = {
43768
+ a: new Set(["href"]),
43769
+ code: new Set(["class"]),
43770
+ span: new Set(["class"]),
43771
+ "tg-emoji": new Set(["emoji-id"]),
43772
+ blockquote: new Set(["expandable"]),
43773
+ pre: new Set(["language"])
43774
+ };
43775
+ var ALLOWED_HREF_SCHEMES2 = /^(?:https?|mailto|tel|tg):/i;
43776
+ function escapeAllHtml2(text) {
43777
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43778
+ }
43779
+ function sanitizeTelegramHtml2(input) {
43780
+ const out = [];
43781
+ const stack = [];
43782
+ let i = 0;
43783
+ const len = input.length;
43784
+ while (i < len) {
43785
+ const ch = input[i];
43786
+ if (ch === "&") {
43787
+ const m = /^&(?:#\d+|#x[0-9a-f]+|[a-z]+);/i.exec(input.slice(i, i + 12));
43788
+ if (m) {
43789
+ out.push(m[0]);
43790
+ i += m[0].length;
43791
+ } else {
43792
+ out.push("&amp;");
43793
+ i++;
43794
+ }
43795
+ continue;
43796
+ }
43797
+ if (ch !== "<") {
43798
+ out.push(ch);
43799
+ i++;
43800
+ continue;
43801
+ }
43802
+ const tagMatch = /^<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/.exec(input.slice(i));
43803
+ if (!tagMatch) {
43804
+ out.push("&lt;");
43805
+ i++;
43806
+ continue;
43807
+ }
43808
+ const isClose = tagMatch[1] === "/";
43809
+ const tagName = tagMatch[2].toLowerCase();
43810
+ const attrText = tagMatch[3];
43811
+ if (!ALLOWED_TAGS2.has(tagName)) {
43812
+ out.push(escapeAllHtml2(tagMatch[0]));
43813
+ i += tagMatch[0].length;
43814
+ continue;
43815
+ }
43816
+ if (isClose) {
43817
+ const idx = stack.lastIndexOf(tagName);
43818
+ if (idx === -1) {
43819
+ i += tagMatch[0].length;
43820
+ continue;
43821
+ }
43822
+ while (stack.length > idx + 1) {
43823
+ const top = stack.pop();
43824
+ out.push(`</${top}>`);
43825
+ }
43826
+ stack.pop();
43827
+ out.push(`</${tagName}>`);
43828
+ i += tagMatch[0].length;
43829
+ continue;
43830
+ }
43831
+ const cleanAttrs = sanitizeAttrs2(tagName, attrText);
43832
+ out.push(`<${tagName}${cleanAttrs}>`);
43833
+ stack.push(tagName);
43834
+ i += tagMatch[0].length;
43835
+ }
43836
+ while (stack.length > 0) {
43837
+ const top = stack.pop();
43838
+ out.push(`</${top}>`);
43839
+ }
43840
+ return out.join("");
43841
+ }
43842
+ function sanitizeAttrs2(tagName, attrText) {
43843
+ const allowed = ALLOWED_ATTRS2[tagName];
43844
+ if (!allowed || allowed.size === 0)
43845
+ return "";
43846
+ const attrRe = /([a-zA-Z_][a-zA-Z0-9_-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
43847
+ const kept = [];
43848
+ let m;
43849
+ while ((m = attrRe.exec(attrText)) != null) {
43850
+ const name = m[1].toLowerCase();
43851
+ if (!allowed.has(name))
43852
+ continue;
43853
+ const rawValue = m[2] ?? m[3] ?? m[4] ?? "";
43854
+ if (tagName === "a" && name === "href") {
43855
+ const trimmed = rawValue.trim();
43856
+ if (!ALLOWED_HREF_SCHEMES2.test(trimmed))
43857
+ continue;
43858
+ kept.push(`href="${escapeAttrValue2(trimmed)}"`);
43859
+ continue;
43860
+ }
43861
+ if (rawValue.length === 0) {
43862
+ kept.push(name);
43863
+ continue;
43864
+ }
43865
+ kept.push(`${name}="${escapeAttrValue2(rawValue)}"`);
43866
+ }
43867
+ return kept.length > 0 ? " " + kept.join(" ") : "";
43868
+ }
43869
+ function escapeAttrValue2(v) {
43870
+ return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
43871
+ }
43872
+
43711
43873
  // text-voice-scrub.ts
43712
43874
  var NULL = "\x00";
43713
43875
  var FENCE_PH = `${NULL}VS_FENCE`;
@@ -46272,7 +46434,7 @@ async function handleModelMenuCallback(data, deps) {
46272
46434
  `).map((l) => l.trim()).find((l) => /set model|switched/i.test(l)) ?? `Switched to ${friendlyName} (session)`;
46273
46435
  return {
46274
46436
  answer: confirmation,
46275
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
46437
+ reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
46276
46438
  selectedModel: srName
46277
46439
  };
46278
46440
  }
@@ -46334,6 +46496,14 @@ async function menuWithBanner(deps, banner) {
46334
46496
  ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
46335
46497
  };
46336
46498
  }
46499
+ async function menuWithBannerStatic(deps, banner) {
46500
+ const v1 = await handleModelCommand({ kind: "show" }, deps);
46501
+ return {
46502
+ text: [banner, "", v1.text].join(`
46503
+ `),
46504
+ html: true
46505
+ };
46506
+ }
46337
46507
 
46338
46508
  // ../src/agents/model-picker.ts
46339
46509
  var HEADER_RE = /Select model/;
@@ -50469,6 +50639,17 @@ function decideFeedReopen(input) {
50469
50639
  }
50470
50640
 
50471
50641
  // gateway/feed-open-gate.ts
50642
+ function shouldEarlyOpenLiveness(input) {
50643
+ if (!input.enabled)
50644
+ return false;
50645
+ if (input.sessionChatId == null)
50646
+ return false;
50647
+ if (input.activityMessageId != null)
50648
+ return false;
50649
+ if (input.ageMs < input.thresholdMs)
50650
+ return false;
50651
+ return true;
50652
+ }
50472
50653
  function mayOpenActivityCard(input) {
50473
50654
  if (input.crossTurnAnswerDelivered)
50474
50655
  return false;
@@ -54753,6 +54934,7 @@ var ARG_SUMMARY_LINE_MAX = 180;
54753
54934
  var MCP_TOOL_DESCRIPTIONS = {
54754
54935
  "mcp__agent-config__config_get": "Read its own merged config",
54755
54936
  "mcp__agent-config__cron_list": "List its own scheduled tasks",
54937
+ "mcp__agent-config__cron_doctor": "Health-check its own cron schedule",
54756
54938
  "mcp__agent-config__skill_list": "List its own installed skills",
54757
54939
  "mcp__agent-config__audit_tail": "Read its own recent tool-call audit log",
54758
54940
  "mcp__agent-config__peers_list": "List the other agents on this instance",
@@ -56064,10 +56246,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56064
56246
  }
56065
56247
 
56066
56248
  // ../src/build-info.ts
56067
- var VERSION = "0.16.14";
56068
- var COMMIT_SHA = "6daa5e37";
56069
- var COMMIT_DATE = "2026-06-28T19:14:51Z";
56070
- var LATEST_PR = 2639;
56249
+ var VERSION = "0.16.17";
56250
+ var COMMIT_SHA = "de202ab1";
56251
+ var COMMIT_DATE = "2026-06-29T02:55:27Z";
56252
+ var LATEST_PR = 2652;
56071
56253
  var COMMITS_AHEAD_OF_TAG = 0;
56072
56254
 
56073
56255
  // gateway/boot-version.ts
@@ -58067,7 +58249,7 @@ var FEED_LIVENESS_OPEN_ENABLED = process.env.SWITCHROOM_FEED_LIVENESS_OPEN !== "
58067
58249
  var FEED_LIVENESS_OPEN_MS = (() => {
58068
58250
  const raw = process.env.SWITCHROOM_FEED_LIVENESS_OPEN_MS;
58069
58251
  const n = raw ? Number(raw) : NaN;
58070
- return Number.isFinite(n) && n > 0 ? n : 12000;
58252
+ return Number.isFinite(n) && n > 0 ? n : 1200;
58071
58253
  })();
58072
58254
  var POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(process.env.SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS) || 30000;
58073
58255
  function formatFeedElapsed3(ms) {
@@ -58475,6 +58657,7 @@ function purgeReactionTracking(key, endingTurn) {
58475
58657
  const threadId = threadPart === "_" || threadPart === "" ? null : Number(threadPart);
58476
58658
  stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null);
58477
58659
  }
58660
+ stopEarlyLivenessOpen(key);
58478
58661
  if (msgInfo) {
58479
58662
  const agentDir = resolveAgentDirFromEnv();
58480
58663
  if (agentDir != null)
@@ -58673,34 +58856,13 @@ function maybeIdleClear() {
58673
58856
  idleClearDispatching = true;
58674
58857
  process.stderr.write(`telegram gateway: idle auto-/clear for ${agentName3} (idle >= ${Math.round(idleClearMs / 60000)}m)
58675
58858
  `);
58676
- injectSlashCommand(agentName3, "/clear").then(() => {
58677
- postIdleClearNotice(idleClearMs);
58678
- }).catch((err) => {
58859
+ injectSlashCommand(agentName3, "/clear").catch((err) => {
58679
58860
  process.stderr.write(`telegram gateway: idle /clear inject failed for ${agentName3}: ${err instanceof Error ? err.message : String(err)}
58680
58861
  `);
58681
58862
  }).finally(() => {
58682
58863
  idleClearDispatching = false;
58683
58864
  });
58684
58865
  }
58685
- async function postIdleClearNotice(idleClearMs) {
58686
- try {
58687
- const chatId = loadAccess().allowFrom[0];
58688
- if (!chatId)
58689
- return;
58690
- const threadId = topicForRecipient({
58691
- recipientChatId: chatId,
58692
- resolvedTopic: resolveAgentOutboundTopic({ kind: "compact-watchdog" }) ?? chatThreadMap.get(chatId),
58693
- supergroupChatId: resolveAgentSupergroupChatId()
58694
- });
58695
- const hrs = Math.round(idleClearMs / 3600000 * 10) / 10;
58696
- const text2 = `\uD83E\uDDF9 <b>Cleared after ${hrs}h idle</b> \u2014 fresh slate next message; ` + `long-term memory is in Hindsight.`;
58697
- await swallowingApiCall(() => bot.api.sendMessage(chatId, text2, {
58698
- parse_mode: "HTML",
58699
- disable_notification: true,
58700
- ...threadId != null ? { message_thread_id: threadId } : {}
58701
- }), { chat_id: chatId, verb: "idleAutoClear.notice" });
58702
- } catch {}
58703
- }
58704
58866
  async function postCompactCard(occ, cap) {
58705
58867
  try {
58706
58868
  const chatId = loadAccess().allowFrom[0];
@@ -58974,24 +59136,18 @@ function stopTypingLoop(chat_id, thread_id = null) {
58974
59136
  typingRetryTimers.delete(key);
58975
59137
  }
58976
59138
  }
58977
- var turnTypingIntervals = new Map;
58978
- function startTurnTypingLoop(chat_id, thread_id = null) {
58979
- stopTurnTypingLoop(chat_id, thread_id);
58980
- const key = chatKey2(chat_id, thread_id);
58981
- const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined;
58982
- const send = () => {
59139
+ var turnTypingLoop = createTurnTypingLoop({
59140
+ sendChatAction: (chat_id, thread_id) => {
59141
+ const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined;
58983
59142
  bot.api.sendChatAction(chat_id, "typing", sendOpts).catch(() => {});
58984
- };
58985
- send();
58986
- turnTypingIntervals.set(key, setInterval(send, 4000));
59143
+ },
59144
+ chatKey: (chat_id, thread_id) => chatKey2(chat_id, thread_id)
59145
+ });
59146
+ function startTurnTypingLoop(chat_id, thread_id = null) {
59147
+ turnTypingLoop.start(chat_id, thread_id);
58987
59148
  }
58988
59149
  function stopTurnTypingLoop(chat_id, thread_id = null) {
58989
- const key = chatKey2(chat_id, thread_id);
58990
- const iv = turnTypingIntervals.get(key);
58991
- if (iv) {
58992
- clearInterval(iv);
58993
- turnTypingIntervals.delete(key);
58994
- }
59150
+ turnTypingLoop.stop(chat_id, thread_id);
58995
59151
  }
58996
59152
  var typingWrapper = createTypingWrapper({
58997
59153
  startTypingLoop,
@@ -62610,6 +62766,62 @@ async function drainActivitySummary(turn, producer = "tool", openFlags) {
62610
62766
  turn.activityInFlight = null;
62611
62767
  }
62612
62768
  }
62769
+ function openLivenessFeedIfDue(turn) {
62770
+ const age = Date.now() - turn.startedAt;
62771
+ if (!shouldEarlyOpenLiveness({
62772
+ enabled: FEED_LIVENESS_OPEN_ENABLED,
62773
+ ageMs: age,
62774
+ thresholdMs: FEED_LIVENESS_OPEN_MS,
62775
+ mirrorLineCount: turn.mirrorLines.length,
62776
+ activityMessageId: turn.activityMessageId,
62777
+ sessionChatId: turn.sessionChatId
62778
+ }))
62779
+ return;
62780
+ const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ["Working\u2026"];
62781
+ const livenessHeader = {
62782
+ label: "Agent",
62783
+ elapsedMs: age,
62784
+ toolCount: turn.labeledToolCount,
62785
+ state: "running"
62786
+ };
62787
+ const rendered = renderActivityFeedWithNested(lines, [], false, ` \xB7 ${formatFeedElapsed3(age)}`, undefined, livenessHeader);
62788
+ if (rendered == null)
62789
+ return;
62790
+ turn.activityPendingRender = rendered;
62791
+ const ea = emissionAuthorityFor(turn);
62792
+ cardDrainGate(turn, ea, () => {
62793
+ if (ea.mayDrain(turn)) {
62794
+ ea.openOrEditCard("liveness", () => {
62795
+ turn.activityInFlight = drainActivitySummary(turn, "liveness");
62796
+ });
62797
+ }
62798
+ });
62799
+ }
62800
+ var earlyLivenessOpenTimers = new Map;
62801
+ function scheduleEarlyLivenessOpen(turn) {
62802
+ if (STATIC || !FEED_HEARTBEAT_ENABLED || !FEED_LIVENESS_OPEN_ENABLED)
62803
+ return;
62804
+ if (turn.sessionChatId == null)
62805
+ return;
62806
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId);
62807
+ stopEarlyLivenessOpen(key);
62808
+ const t = setTimeout(() => {
62809
+ earlyLivenessOpenTimers.delete(key);
62810
+ const live = currentTurnMap.get(key);
62811
+ if (live == null || live.turnId !== turn.turnId)
62812
+ return;
62813
+ openLivenessFeedIfDue(live);
62814
+ }, FEED_LIVENESS_OPEN_MS);
62815
+ t.unref?.();
62816
+ earlyLivenessOpenTimers.set(key, t);
62817
+ }
62818
+ function stopEarlyLivenessOpen(key) {
62819
+ const t = earlyLivenessOpenTimers.get(key);
62820
+ if (t != null) {
62821
+ clearTimeout(t);
62822
+ earlyLivenessOpenTimers.delete(key);
62823
+ }
62824
+ }
62613
62825
  function feedHeartbeatTick() {
62614
62826
  const turn = currentTurn;
62615
62827
  if (turn == null)
@@ -62650,29 +62862,7 @@ function feedHeartbeatTick() {
62650
62862
  return;
62651
62863
  }
62652
62864
  if (turn.mirrorLines.length === 0) {
62653
- if (!FEED_LIVENESS_OPEN_ENABLED || turn.sessionChatId == null)
62654
- return;
62655
- const age = Date.now() - turn.startedAt;
62656
- if (age < FEED_LIVENESS_OPEN_MS)
62657
- return;
62658
- const livenessHeader = {
62659
- label: "Agent",
62660
- elapsedMs: age,
62661
- toolCount: 0,
62662
- state: "running"
62663
- };
62664
- const rendered2 = renderActivityFeedWithNested(["Working\u2026"], [], false, ` \xB7 ${formatFeedElapsed3(age)}`, undefined, livenessHeader);
62665
- if (rendered2 == null)
62666
- return;
62667
- turn.activityPendingRender = rendered2;
62668
- const ea2 = emissionAuthorityFor(turn);
62669
- cardDrainGate(turn, ea2, () => {
62670
- if (ea2.mayDrain(turn)) {
62671
- ea2.openOrEditCard("liveness", () => {
62672
- turn.activityInFlight = drainActivitySummary(turn, "liveness");
62673
- });
62674
- }
62675
- });
62865
+ openLivenessFeedIfDue(turn);
62676
62866
  return;
62677
62867
  }
62678
62868
  if (turn.activityMessageId == null)
@@ -62804,6 +62994,7 @@ function handleSessionEvent(ev) {
62804
62994
  };
62805
62995
  setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum));
62806
62996
  markIdleActivity();
62997
+ scheduleEarlyLivenessOpen(next);
62807
62998
  process.stderr.write(`telegram gateway: ${formatTurnLifecycle("set", "enqueue", next, startedAt)}
62808
62999
  `);
62809
63000
  rememberRecentTurn(next);
@@ -62942,6 +63133,7 @@ function handleSessionEvent(ev) {
62942
63133
  chatId: turn.sessionChatId,
62943
63134
  threadId: turn.sessionThreadId,
62944
63135
  minInitialChars: ANSWER_LANE.minInitialChars,
63136
+ renderText: (text2) => sanitizeTelegramHtml2(markdownToHtml(text2)),
62945
63137
  sendMessage: async (chatId, text2, params) => {
62946
63138
  const tid = params?.message_thread_id;
62947
63139
  const silent = params?.purpose !== "materialize";
@@ -69501,9 +69693,10 @@ async function shutdown(signal) {
69501
69693
  for (const iv of [...typingIntervals.values()])
69502
69694
  clearInterval(iv);
69503
69695
  typingIntervals.clear();
69504
- for (const iv of [...turnTypingIntervals.values()])
69505
- clearInterval(iv);
69506
- turnTypingIntervals.clear();
69696
+ turnTypingLoop.stopAll();
69697
+ for (const t of [...earlyLivenessOpenTimers.values()])
69698
+ clearTimeout(t);
69699
+ earlyLivenessOpenTimers.clear();
69507
69700
  for (const t of [...typingRetryTimers.values()])
69508
69701
  clearTimeout(t);
69509
69702
  typingRetryTimers.clear();
@@ -80,10 +80,63 @@
80
80
  * already received.
81
81
  */
82
82
 
83
+ /**
84
+ * Inputs for the liveness early-open WHEN-decision (`shouldEarlyOpenLiveness`).
85
+ * Separate from the OPEN-gate levers above: those answer *may* a card open at
86
+ * all (reply-is-last / cross-turn); this answers *is it time yet* for the
87
+ * minimal "Working…" placeholder on a 0-label turn. Both must say yes — the
88
+ * caller routes the actual open through `mayOpenActivityCard` after this clears.
89
+ */
90
+ export interface EarlyLivenessOpenInput {
91
+ /** Feature flag (`SWITCHROOM_FEED_LIVENESS_OPEN`). Off ⇒ never early-open. */
92
+ enabled: boolean
93
+ /** Turn age in ms (`now - turn.startedAt`). Must be ≥ `thresholdMs`. */
94
+ ageMs: number
95
+ /** The early-open threshold (`FEED_LIVENESS_OPEN_MS`). */
96
+ thresholdMs: number
97
+ /** Count of surfaced tool steps this turn (`turn.mirrorLines.length`). >0 ⇒ a
98
+ * real label already drives the labelled-feed heartbeat; the placeholder must
99
+ * not fight it, so it never opens (unless `forceNarrative` — see below). */
100
+ mirrorLineCount: number
101
+ /** Single in-place card transport id. Non-null ⇒ a card is already OPEN, so
102
+ * this is a maintain/no-op, not a fresh OPEN. */
103
+ activityMessageId: number | null
104
+ /** The session chat id. `null` ⇒ no surface to open on. */
105
+ sessionChatId: string | null
106
+ }
107
+
108
+ /**
109
+ * Pure: is the minimal "Working…" liveness placeholder due to OPEN for a 0-label
110
+ * turn? True iff the feature is on, the turn has a target chat, it has been alive
111
+ * past the threshold, and NO card is already open. Once `mirrorLineCount > 0` a
112
+ * real tool label drives the feed, EXCEPT the edge where narration staged
113
+ * `mirrorLines` but no card opened yet (`activityMessageId == null`) — there the
114
+ * accumulated narration should still render on the early open, so it is allowed.
115
+ *
116
+ * This is the WHEN gate. The caller still routes the OPEN through
117
+ * `mayOpenActivityCard` (lever 1/4) so a card never opens below a delivered
118
+ * answer or on a cross-turn synthetic surface. Two callers consult this — the
119
+ * enqueue-time early-open timer and the 6 s heartbeat — and because an already-
120
+ * open card returns false here (and the drain EDITs rather than re-OPENs), they
121
+ * can never double-open.
122
+ */
123
+ export function shouldEarlyOpenLiveness(input: EarlyLivenessOpenInput): boolean {
124
+ if (!input.enabled) return false
125
+ if (input.sessionChatId == null) return false
126
+ // A card is already open → maintain via the drain's EDIT path, not a fresh
127
+ // OPEN. Never double-open. (Reaching past here implies `activityMessageId ==
128
+ // null`, so a non-zero `mirrorLineCount` is the "narration staged but no card
129
+ // opened yet" edge — allowed below so the accumulated narration renders.)
130
+ if (input.activityMessageId != null) return false
131
+ if (input.ageMs < input.thresholdMs) return false
132
+ return true
133
+ }
134
+
83
135
  /** Which producer triggered this drain — determines lever-5 OPEN eligibility. */
84
136
  export type FeedOpenProducer =
85
137
  /** Narrative SHOW (producer A): plain assistant text, no tool, no time
86
- * threshold. May only EDIT, never OPEN, while the turn has 0 tool labels. */
138
+ * threshold. Pre-answer it is OPEN-eligible (lever 5 INERT); after a
139
+ * substantive final answer it is blocked by lever 1. */
87
140
  | 'narrative'
88
141
  /** Tool label (producer B): the model dispatched a tool. OPEN-eligible unless a
89
142
  * substantive final already landed (lever 1).