switchroom 0.16.15 → 0.16.20

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`;
@@ -50477,6 +50639,17 @@ function decideFeedReopen(input) {
50477
50639
  }
50478
50640
 
50479
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
+ }
50480
50653
  function mayOpenActivityCard(input) {
50481
50654
  if (input.crossTurnAnswerDelivered)
50482
50655
  return false;
@@ -56073,10 +56246,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
56073
56246
  }
56074
56247
 
56075
56248
  // ../src/build-info.ts
56076
- var VERSION = "0.16.15";
56077
- var COMMIT_SHA = "a9c59169";
56078
- var COMMIT_DATE = "2026-06-28T22:48:22Z";
56079
- var LATEST_PR = 2643;
56249
+ var VERSION = "0.16.20";
56250
+ var COMMIT_SHA = "6ea6e873";
56251
+ var COMMIT_DATE = "2026-06-29T05:16:25Z";
56252
+ var LATEST_PR = 2657;
56080
56253
  var COMMITS_AHEAD_OF_TAG = 0;
56081
56254
 
56082
56255
  // gateway/boot-version.ts
@@ -58076,7 +58249,7 @@ var FEED_LIVENESS_OPEN_ENABLED = process.env.SWITCHROOM_FEED_LIVENESS_OPEN !== "
58076
58249
  var FEED_LIVENESS_OPEN_MS = (() => {
58077
58250
  const raw = process.env.SWITCHROOM_FEED_LIVENESS_OPEN_MS;
58078
58251
  const n = raw ? Number(raw) : NaN;
58079
- return Number.isFinite(n) && n > 0 ? n : 12000;
58252
+ return Number.isFinite(n) && n > 0 ? n : 1200;
58080
58253
  })();
58081
58254
  var POST_ANSWER_LIVENESS_STALE_MS = parsePostAnswerLivenessMs(process.env.SWITCHROOM_POST_ANSWER_LIVENESS_STALE_MS) || 30000;
58082
58255
  function formatFeedElapsed3(ms) {
@@ -58484,6 +58657,7 @@ function purgeReactionTracking(key, endingTurn) {
58484
58657
  const threadId = threadPart === "_" || threadPart === "" ? null : Number(threadPart);
58485
58658
  stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null);
58486
58659
  }
58660
+ stopEarlyLivenessOpen(key);
58487
58661
  if (msgInfo) {
58488
58662
  const agentDir = resolveAgentDirFromEnv();
58489
58663
  if (agentDir != null)
@@ -58682,34 +58856,13 @@ function maybeIdleClear() {
58682
58856
  idleClearDispatching = true;
58683
58857
  process.stderr.write(`telegram gateway: idle auto-/clear for ${agentName3} (idle >= ${Math.round(idleClearMs / 60000)}m)
58684
58858
  `);
58685
- injectSlashCommand(agentName3, "/clear").then(() => {
58686
- postIdleClearNotice(idleClearMs);
58687
- }).catch((err) => {
58859
+ injectSlashCommand(agentName3, "/clear").catch((err) => {
58688
58860
  process.stderr.write(`telegram gateway: idle /clear inject failed for ${agentName3}: ${err instanceof Error ? err.message : String(err)}
58689
58861
  `);
58690
58862
  }).finally(() => {
58691
58863
  idleClearDispatching = false;
58692
58864
  });
58693
58865
  }
58694
- async function postIdleClearNotice(idleClearMs) {
58695
- try {
58696
- const chatId = loadAccess().allowFrom[0];
58697
- if (!chatId)
58698
- return;
58699
- const threadId = topicForRecipient({
58700
- recipientChatId: chatId,
58701
- resolvedTopic: resolveAgentOutboundTopic({ kind: "compact-watchdog" }) ?? chatThreadMap.get(chatId),
58702
- supergroupChatId: resolveAgentSupergroupChatId()
58703
- });
58704
- const hrs = Math.round(idleClearMs / 3600000 * 10) / 10;
58705
- const text2 = `\uD83E\uDDF9 <b>Cleared after ${hrs}h idle</b> \u2014 fresh slate next message; ` + `long-term memory is in Hindsight.`;
58706
- await swallowingApiCall(() => bot.api.sendMessage(chatId, text2, {
58707
- parse_mode: "HTML",
58708
- disable_notification: true,
58709
- ...threadId != null ? { message_thread_id: threadId } : {}
58710
- }), { chat_id: chatId, verb: "idleAutoClear.notice" });
58711
- } catch {}
58712
- }
58713
58866
  async function postCompactCard(occ, cap) {
58714
58867
  try {
58715
58868
  const chatId = loadAccess().allowFrom[0];
@@ -58983,24 +59136,18 @@ function stopTypingLoop(chat_id, thread_id = null) {
58983
59136
  typingRetryTimers.delete(key);
58984
59137
  }
58985
59138
  }
58986
- var turnTypingIntervals = new Map;
58987
- function startTurnTypingLoop(chat_id, thread_id = null) {
58988
- stopTurnTypingLoop(chat_id, thread_id);
58989
- const key = chatKey2(chat_id, thread_id);
58990
- const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined;
58991
- const send = () => {
59139
+ var turnTypingLoop = createTurnTypingLoop({
59140
+ sendChatAction: (chat_id, thread_id) => {
59141
+ const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined;
58992
59142
  bot.api.sendChatAction(chat_id, "typing", sendOpts).catch(() => {});
58993
- };
58994
- send();
58995
- 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);
58996
59148
  }
58997
59149
  function stopTurnTypingLoop(chat_id, thread_id = null) {
58998
- const key = chatKey2(chat_id, thread_id);
58999
- const iv = turnTypingIntervals.get(key);
59000
- if (iv) {
59001
- clearInterval(iv);
59002
- turnTypingIntervals.delete(key);
59003
- }
59150
+ turnTypingLoop.stop(chat_id, thread_id);
59004
59151
  }
59005
59152
  var typingWrapper = createTypingWrapper({
59006
59153
  startTypingLoop,
@@ -62619,6 +62766,62 @@ async function drainActivitySummary(turn, producer = "tool", openFlags) {
62619
62766
  turn.activityInFlight = null;
62620
62767
  }
62621
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
+ }
62622
62825
  function feedHeartbeatTick() {
62623
62826
  const turn = currentTurn;
62624
62827
  if (turn == null)
@@ -62659,29 +62862,7 @@ function feedHeartbeatTick() {
62659
62862
  return;
62660
62863
  }
62661
62864
  if (turn.mirrorLines.length === 0) {
62662
- if (!FEED_LIVENESS_OPEN_ENABLED || turn.sessionChatId == null)
62663
- return;
62664
- const age = Date.now() - turn.startedAt;
62665
- if (age < FEED_LIVENESS_OPEN_MS)
62666
- return;
62667
- const livenessHeader = {
62668
- label: "Agent",
62669
- elapsedMs: age,
62670
- toolCount: 0,
62671
- state: "running"
62672
- };
62673
- const rendered2 = renderActivityFeedWithNested(["Working\u2026"], [], false, ` \xB7 ${formatFeedElapsed3(age)}`, undefined, livenessHeader);
62674
- if (rendered2 == null)
62675
- return;
62676
- turn.activityPendingRender = rendered2;
62677
- const ea2 = emissionAuthorityFor(turn);
62678
- cardDrainGate(turn, ea2, () => {
62679
- if (ea2.mayDrain(turn)) {
62680
- ea2.openOrEditCard("liveness", () => {
62681
- turn.activityInFlight = drainActivitySummary(turn, "liveness");
62682
- });
62683
- }
62684
- });
62865
+ openLivenessFeedIfDue(turn);
62685
62866
  return;
62686
62867
  }
62687
62868
  if (turn.activityMessageId == null)
@@ -62813,6 +62994,7 @@ function handleSessionEvent(ev) {
62813
62994
  };
62814
62995
  setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum));
62815
62996
  markIdleActivity();
62997
+ scheduleEarlyLivenessOpen(next);
62816
62998
  process.stderr.write(`telegram gateway: ${formatTurnLifecycle("set", "enqueue", next, startedAt)}
62817
62999
  `);
62818
63000
  rememberRecentTurn(next);
@@ -62951,6 +63133,7 @@ function handleSessionEvent(ev) {
62951
63133
  chatId: turn.sessionChatId,
62952
63134
  threadId: turn.sessionThreadId,
62953
63135
  minInitialChars: ANSWER_LANE.minInitialChars,
63136
+ renderText: (text2) => sanitizeTelegramHtml2(markdownToHtml(text2)),
62954
63137
  sendMessage: async (chatId, text2, params) => {
62955
63138
  const tid = params?.message_thread_id;
62956
63139
  const silent = params?.purpose !== "materialize";
@@ -69510,9 +69693,10 @@ async function shutdown(signal) {
69510
69693
  for (const iv of [...typingIntervals.values()])
69511
69694
  clearInterval(iv);
69512
69695
  typingIntervals.clear();
69513
- for (const iv of [...turnTypingIntervals.values()])
69514
- clearInterval(iv);
69515
- turnTypingIntervals.clear();
69696
+ turnTypingLoop.stopAll();
69697
+ for (const t of [...earlyLivenessOpenTimers.values()])
69698
+ clearTimeout(t);
69699
+ earlyLivenessOpenTimers.clear();
69516
69700
  for (const t of [...typingRetryTimers.values()])
69517
69701
  clearTimeout(t);
69518
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).