switchroom 0.16.15 → 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.
@@ -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).
@@ -79,6 +79,7 @@ import { appendActivityLabel, clipNarrative, renderActivityFeedWithNested, type
79
79
  import { REPLY_TOOLS, isDraftOfReply } from '../narrative-dedup.js'
80
80
  import { toolLabel } from '../tool-labels.js'
81
81
  import { createTypingWrapper } from '../typing-wrap.js'
82
+ import { createTurnTypingLoop } from './turn-typing-loop.js'
82
83
  import { type DraftStreamHandle } from '../draft-stream.js'
83
84
  import { handlePtyPartialPure, type PtyHandlerState } from '../pty-partial-handler.js'
84
85
  import { handleStreamReply } from '../stream-reply-handler.js'
@@ -189,6 +190,7 @@ const SILENT_END_FALLBACK_TEXT =
189
190
  '⚠️ The agent finished working but didn’t send a reply — your last ' +
190
191
  'message may not have been answered. Please try asking again.'
191
192
  import { markdownToHtml, splitHtmlChunks, repairEscapedWhitespace, telegramHtmlToPlainText } from '../format.js'
193
+ import { sanitizeTelegramHtml } from '../html-sanitize.js'
192
194
  import { scrubVoice } from '../text-voice-scrub.js'
193
195
  import {
194
196
  validateInlineKeyboard,
@@ -343,6 +345,7 @@ import { decideFeedReopen } from './feed-reopen-gate.js'
343
345
  import {
344
346
  mayOpenActivityCard,
345
347
  computeCrossTurnAnswerDelivered,
348
+ shouldEarlyOpenLiveness,
346
349
  type FeedOpenProducer,
347
350
  type FeedOpenGateDeps,
348
351
  } from './feed-open-gate.js'
@@ -1767,14 +1770,25 @@ const FEED_HEARTBEAT_MIN_STALE_MS = 6_000
1767
1770
  // the 300s silence-poke (the #680 dark-turn). When a turn has been alive >=
1768
1771
  // FEED_LIVENESS_OPEN_MS with no feed yet, open a minimal "Working…" feed so the
1769
1772
  // user always has a live indicator; the first real tool label edits it with
1770
- // real content. Runs on the heartbeat interval, so the effective open lands in
1771
- // [threshold, threshold + FEED_HEARTBEAT_TICK_MS). Kill switch:
1773
+ // real content.
1774
+ //
1775
+ // Two callers reach the SAME open path (`openLivenessFeedIfDue`):
1776
+ // 1. an enqueue-time one-shot timer (`scheduleEarlyLivenessOpen`) fires at
1777
+ // `FEED_LIVENESS_OPEN_MS` after turn start — this is the early-open that
1778
+ // kills the dead-air gap (narration emitted before the first tool now
1779
+ // surfaces ~`FEED_LIVENESS_OPEN_MS` after enqueue, not after a 6 s
1780
+ // heartbeat phase + the old 12 s threshold);
1781
+ // 2. the 6 s heartbeat tick, which maintains/climbs the card and is the
1782
+ // backstop if the one-shot ever missed (e.g. a clock skew).
1783
+ // The threshold dropped from 12 s → ~1.2 s so the "something is happening"
1784
+ // signal lands within a second or two of the inbound, matching the JTBD
1785
+ // `know-what-my-agent-is-doing` p95 ≤ ~1 s ambient-ack bar. Kill switch:
1772
1786
  // SWITCHROOM_FEED_LIVENESS_OPEN=0. Default on.
1773
1787
  const FEED_LIVENESS_OPEN_ENABLED = process.env.SWITCHROOM_FEED_LIVENESS_OPEN !== '0'
1774
1788
  const FEED_LIVENESS_OPEN_MS = (() => {
1775
1789
  const raw = process.env.SWITCHROOM_FEED_LIVENESS_OPEN_MS
1776
1790
  const n = raw ? Number(raw) : NaN
1777
- return Number.isFinite(n) && n > 0 ? n : 12_000
1791
+ return Number.isFinite(n) && n > 0 ? n : 1_200
1778
1792
  })()
1779
1793
 
1780
1794
  // Post-answer background-agent liveness STALENESS CAP (Fix 2 / #2587 supersede,
@@ -3146,6 +3160,13 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3146
3160
  const threadId = threadPart === '_' || threadPart === '' ? null : Number(threadPart)
3147
3161
  stopTurnTypingLoop(chatId, Number.isFinite(threadId) ? threadId : null)
3148
3162
  }
3163
+ // Cancel the enqueue-time early-open timer (paired with
3164
+ // `scheduleEarlyLivenessOpen` at turn start). `key` is the same status-key the
3165
+ // timer was registered under, so this is the single owner of the cancel. A
3166
+ // leaked timer would otherwise fire its `openLivenessFeedIfDue` against a
3167
+ // successor turn; the timer's own turnId match is the second guard, this is
3168
+ // the first. Idempotent — a no-op when no timer is registered.
3169
+ stopEarlyLivenessOpen(key as string)
3149
3170
  if (msgInfo) {
3150
3171
  const agentDir = resolveAgentDirFromEnv()
3151
3172
  if (agentDir != null) removeActiveReaction(agentDir, msgInfo.chatId, msgInfo.messageId)
@@ -3603,7 +3624,6 @@ function maybeIdleClear(): void {
3603
3624
  // could arrive between the gate check and the tmux send; /clear then lands in
3604
3625
  // claude's prompt buffer and runs at the next idle prompt (inject.ts FUTURE-GAP).
3605
3626
  void injectSlashCommandImpl(agentName, '/clear')
3606
- .then(() => { void postIdleClearNotice(idleClearMs); })
3607
3627
  .catch((err: unknown) => {
3608
3628
  process.stderr.write(
3609
3629
  `telegram gateway: idle /clear inject failed for ` +
@@ -3613,36 +3633,6 @@ function maybeIdleClear(): void {
3613
3633
  .finally(() => { idleClearDispatching = false; });
3614
3634
  }
3615
3635
 
3616
- /** Subtle one-line notice so the operator knows the session was auto-cleared. */
3617
- async function postIdleClearNotice(idleClearMs: number): Promise<void> {
3618
- try {
3619
- const chatId = loadAccess().allowFrom[0];
3620
- if (!chatId) return;
3621
- const threadId = topicForRecipient({
3622
- recipientChatId: chatId,
3623
- resolvedTopic:
3624
- resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
3625
- ?? chatThreadMap.get(chatId),
3626
- supergroupChatId: resolveAgentSupergroupChatId(),
3627
- });
3628
- const hrs = Math.round((idleClearMs / 3_600_000) * 10) / 10;
3629
- const text =
3630
- `🧹 <b>Cleared after ${hrs}h idle</b> — fresh slate next message; ` +
3631
- `long-term memory is in Hindsight.`;
3632
- await swallowingApiCall(
3633
- () =>
3634
- bot.api.sendMessage(chatId, text, {
3635
- parse_mode: 'HTML',
3636
- disable_notification: true,
3637
- ...(threadId != null ? { message_thread_id: threadId } : {}),
3638
- }),
3639
- { chat_id: chatId, verb: 'idleAutoClear.notice' },
3640
- );
3641
- } catch {
3642
- /* best-effort notice — the /clear itself still happened */
3643
- }
3644
- }
3645
-
3646
3636
  /**
3647
3637
  * Post the START card for a proactive compaction. Best-effort: a failed
3648
3638
  * send just means no card (the compaction itself still happens). The
@@ -4131,33 +4121,31 @@ function stopTypingLoop(chat_id: string, thread_id: number | null = null): void
4131
4121
  if (retry) { clearTimeout(retry); typingRetryTimers.delete(key) }
4132
4122
  }
4133
4123
 
4134
- // Turn-level `typing…` indicator. Deliberately a SEPARATE interval map
4135
- // from `typingIntervals` (which the reply handler and the tool-use
4136
- // typing wrapper share and freely stop). If the turn loop lived in the
4137
- // shared map, a mid-turn reply's `finally { stopTypingLoop }` would
4138
- // kill it and the chat would go dark for the rest of the turn — the
4139
- // exact black-box gap this is here to close. A dedicated map makes the
4140
- // turn loop structurally immune to those stops: only `stopTurnTypingLoop`
4141
- // (called at the canonical turn-end) clears it. The redundant `typing`
4142
- // pings while a reply is mid-flight are harmless same action, and
4143
- // sendChatAction is cheap.
4144
- const turnTypingIntervals = new Map<string, ReturnType<typeof setInterval>>()
4124
+ // Turn-level `typing…` indicator. The lifecycle (immediate fire, 4 s refresh,
4125
+ // stop-at-turn-end, no leak) lives in the testable `turn-typing-loop.ts`
4126
+ // factory; the gateway just injects the real `sendChatAction` + `chatKey`.
4127
+ // Deliberately a SEPARATE interval map from `typingIntervals` (the reply handler
4128
+ // + tool-use typing wrapper share that one and freely stop it). If the turn loop
4129
+ // lived in the shared map, a mid-turn reply's `finally { stopTypingLoop }` would
4130
+ // kill it and the chat would go dark for the rest of the turn — the exact
4131
+ // black-box gap this closes. The dedicated map (private to the factory) makes
4132
+ // the turn loop structurally immune to those stops: only the canonical turn-end
4133
+ // stop clears it. The redundant `typing` pings while a reply is mid-flight are
4134
+ // harmless same action, and sendChatAction is cheap.
4135
+ const turnTypingLoop = createTurnTypingLoop({
4136
+ sendChatAction: (chat_id, thread_id) => {
4137
+ const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
4138
+ void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
4139
+ },
4140
+ chatKey: (chat_id, thread_id) => chatKey(chat_id, thread_id) as string,
4141
+ })
4145
4142
 
4146
4143
  function startTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
4147
- stopTurnTypingLoop(chat_id, thread_id)
4148
- const key = chatKey(chat_id, thread_id) as string
4149
- const sendOpts = thread_id != null ? { message_thread_id: thread_id } : undefined
4150
- const send = () => {
4151
- void bot.api.sendChatAction(chat_id, 'typing', sendOpts).catch(() => {})
4152
- }
4153
- send()
4154
- turnTypingIntervals.set(key, setInterval(send, 4000))
4144
+ turnTypingLoop.start(chat_id, thread_id)
4155
4145
  }
4156
4146
 
4157
4147
  function stopTurnTypingLoop(chat_id: string, thread_id: number | null = null): void {
4158
- const key = chatKey(chat_id, thread_id) as string
4159
- const iv = turnTypingIntervals.get(key)
4160
- if (iv) { clearInterval(iv); turnTypingIntervals.delete(key) }
4148
+ turnTypingLoop.stop(chat_id, thread_id)
4161
4149
  }
4162
4150
 
4163
4151
  const typingWrapper = createTypingWrapper({
@@ -10782,11 +10770,12 @@ function showNarrativeStep(turn: CurrentTurn, text: string): void {
10782
10770
  // card-drain gate (chatLock-serialized under the flag; verbatim block OFF).
10783
10771
  cardDrainGate(turn, ea, () => {
10784
10772
  if (ea.mayDrain(turn)) {
10785
- // Producer A (narrative SHOW): may only EDIT an already-open card, never
10786
- // OPEN one on a 0-tool turn (design §9 lever 5 base case the
10787
- // triplication). The OPEN gate in the drain enforces this; accumulation
10788
- // into mirrorLines still happens so the narration renders once a tool
10789
- // label or liveness opens the card.
10773
+ // Producer A (narrative SHOW): pre-answer narrative may now OPEN a card,
10774
+ // not just EDIT one lever 5 is INERT (see feed-open-gate.ts), and
10775
+ // Lever 2 / clearActivitySummary guarantees reply-is-last ordering instead.
10776
+ // Accumulation into mirrorLines still happens, so any narration staged
10777
+ // before the card opened renders on the first OPEN (whichever producer
10778
+ // wins the race — narrative here, or the enqueue/liveness timer).
10790
10779
  // PR-4a: routed through the emission-authority façade (no-op delegate).
10791
10780
  ea.openOrEditCard('narrative', () => {
10792
10781
  turn.activityInFlight = drainActivitySummary(turn, 'narrative')
@@ -10986,6 +10975,105 @@ async function drainActivitySummary(
10986
10975
  }
10987
10976
  }
10988
10977
 
10978
+ /**
10979
+ * Open (or climb) the minimal "Working…" liveness card for a 0-label turn once
10980
+ * it has been alive >= FEED_LIVENESS_OPEN_MS. The ONE place the liveness card
10981
+ * may OPEN — both the enqueue-time early-open timer
10982
+ * (`scheduleEarlyLivenessOpen`) and the 6 s heartbeat call through here, so a
10983
+ * card opened by one caller is a clean no-op for the other:
10984
+ * - `drainActivitySummary` OPENs when `activityMessageId == null` and EDITs
10985
+ * once it is set, so a second call after an open just maintains the card;
10986
+ * - the `mirrorLines.length === 0` guard at the heartbeat call site (and the
10987
+ * drain's own gate) means once a real tool label lands this path is skipped
10988
+ * and the labelled-feed heartbeat takes over;
10989
+ * - the OPEN itself is still gated by `mayOpenActivityCard` (lever 1 / 4) via
10990
+ * `ea.openOrEditCard('liveness', …)`, so a card never opens below a
10991
+ * delivered answer or on a cross-turn synthetic surface.
10992
+ *
10993
+ * Renders the turn's accumulated narration when present (the §3 case: narration
10994
+ * staged before the first tool via `mirrorLines`) so the early open is not a
10995
+ * bare placeholder when there is real text to show; falls back to "Working…"
10996
+ * for a genuinely silent thinking turn.
10997
+ */
10998
+ function openLivenessFeedIfDue(turn: CurrentTurn): void {
10999
+ const age = Date.now() - turn.startedAt
11000
+ // The WHEN decision (pure, `feed-open-gate.ts`): feature on, target chat,
11001
+ // past threshold, no card already open. Returns false once a card is open
11002
+ // (the drain EDITs instead) so the two callers can never double-open.
11003
+ if (!shouldEarlyOpenLiveness({
11004
+ enabled: FEED_LIVENESS_OPEN_ENABLED,
11005
+ ageMs: age,
11006
+ thresholdMs: FEED_LIVENESS_OPEN_MS,
11007
+ mirrorLineCount: turn.mirrorLines.length,
11008
+ activityMessageId: turn.activityMessageId,
11009
+ sessionChatId: turn.sessionChatId,
11010
+ })) return
11011
+ const lines = turn.mirrorLines.length > 0 ? turn.mirrorLines : ['Working…']
11012
+ const livenessHeader: SessionActivityHeader = {
11013
+ label: 'Agent', elapsedMs: age, toolCount: turn.labeledToolCount, state: 'running',
11014
+ }
11015
+ const rendered = renderActivityFeedWithNested(lines, [], false, ` · ${formatFeedElapsed(age)}`, undefined, livenessHeader)
11016
+ if (rendered == null) return
11017
+ turn.activityPendingRender = rendered
11018
+ const ea = emissionAuthorityFor(turn)
11019
+ // PR-4d: route through the centralized chatLock-serialized card-drain gate.
11020
+ cardDrainGate(turn, ea, () => {
11021
+ if (ea.mayDrain(turn)) {
11022
+ // Producer C (liveness timer): the thinking-gap / early-open. Now that
11023
+ // Lever 5 is inert (narrative may open pre-answer — #2588), liveness
11024
+ // remains the natural open for 0-tool pre-answer turns that are silent.
11025
+ // The sticky-latch (lever 1) still gates it in the drain.
11026
+ // PR-4a: routed through the emission-authority façade (no-op delegate).
11027
+ ea.openOrEditCard('liveness', () => {
11028
+ turn.activityInFlight = drainActivitySummary(turn, 'liveness')
11029
+ })
11030
+ }
11031
+ })
11032
+ }
11033
+
11034
+ // Enqueue-time early-open timers, keyed by status-key. One per in-flight turn;
11035
+ // cleared at turn-end (`stopEarlyLivenessOpen`) so a leaked timer can never fire
11036
+ // against a successor turn. `unref()` so it never holds the process alive.
11037
+ const earlyLivenessOpenTimers = new Map<string, ReturnType<typeof setTimeout>>()
11038
+
11039
+ /**
11040
+ * Schedule the enqueue-time early-open of the "Working…" liveness card. Called
11041
+ * once per fresh turn at the `enqueue` lifecycle event (the single chokepoint
11042
+ * every real turn atom passes through — inbound, cron, subagent-handback,
11043
+ * vault-resume, restart-marker; anonymous one-shot hook clients never emit
11044
+ * `enqueue`, so they are excluded by construction). Fires `openLivenessFeedIfDue`
11045
+ * once at `FEED_LIVENESS_OPEN_MS` after turn start so narration / thinking that
11046
+ * happens BEFORE the first tool surfaces a card within ~a second — no more dead
11047
+ * air until a tool label or the old 12 s threshold. A no-op if a tool/narrative
11048
+ * already opened the card (the helper's own guards). The 6 s heartbeat remains
11049
+ * the backstop + the climb.
11050
+ */
11051
+ function scheduleEarlyLivenessOpen(turn: CurrentTurn): void {
11052
+ if (STATIC || !FEED_HEARTBEAT_ENABLED || !FEED_LIVENESS_OPEN_ENABLED) return
11053
+ if (turn.sessionChatId == null) return
11054
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId)
11055
+ stopEarlyLivenessOpen(key)
11056
+ const t = setTimeout(() => {
11057
+ earlyLivenessOpenTimers.delete(key)
11058
+ // Re-resolve the live turn for this key: only open if THIS turn is still the
11059
+ // live one for its topic (a successor turn would carry its own timer). Under
11060
+ // flag OFF `get(key)` returns the singleton — same turn unless a successor
11061
+ // already replaced it, which the turnId match below also guards.
11062
+ const live = currentTurnMap.get(key)
11063
+ if (live == null || live.turnId !== turn.turnId) return
11064
+ openLivenessFeedIfDue(live)
11065
+ }, FEED_LIVENESS_OPEN_MS)
11066
+ t.unref?.()
11067
+ earlyLivenessOpenTimers.set(key, t)
11068
+ }
11069
+
11070
+ /** Cancel the enqueue-time early-open timer for a status-key (turn-end teardown
11071
+ * + re-arm guard). Idempotent. */
11072
+ function stopEarlyLivenessOpen(key: string): void {
11073
+ const t = earlyLivenessOpenTimers.get(key)
11074
+ if (t != null) { clearTimeout(t); earlyLivenessOpenTimers.delete(key) }
11075
+ }
11076
+
10989
11077
  /**
10990
11078
  * Heartbeat tick (PR1): keep the live activity feed visibly advancing during a
10991
11079
  * long single step that emits no new tool_label. Re-renders the feed with a
@@ -11071,30 +11159,13 @@ function feedHeartbeatTick(): void {
11071
11159
  // over and its edit cleanly replaces the placeholder. drainActivitySummary
11072
11160
  // sends (opens) when activityMessageId is null and edits (maintains) once set
11073
11161
  // — so this one branch handles both the open and the climb.
11162
+ //
11163
+ // The open/climb logic lives in ONE place (`openLivenessFeedIfDue`) so the
11164
+ // enqueue-time early-open timer (`scheduleEarlyLivenessOpen`) and this 6 s
11165
+ // heartbeat both reach the same drain — there is exactly one path that can
11166
+ // OPEN the liveness card, so the two callers can never double-open or race.
11074
11167
  if (turn.mirrorLines.length === 0) {
11075
- if (!FEED_LIVENESS_OPEN_ENABLED || turn.sessionChatId == null) return
11076
- const age = Date.now() - turn.startedAt
11077
- if (age < FEED_LIVENESS_OPEN_MS) return
11078
- const livenessHeader: SessionActivityHeader = {
11079
- label: 'Agent', elapsedMs: age, toolCount: 0, state: 'running',
11080
- }
11081
- const rendered = renderActivityFeedWithNested(['Working…'], [], false, ` · ${formatFeedElapsed(age)}`, undefined, livenessHeader)
11082
- if (rendered == null) return
11083
- turn.activityPendingRender = rendered
11084
- const ea = emissionAuthorityFor(turn)
11085
- // PR-4d: route through the centralized chatLock-serialized card-drain gate.
11086
- cardDrainGate(turn, ea, () => {
11087
- if (ea.mayDrain(turn)) {
11088
- // Producer C (liveness timer): the genuine ≥12s thinking-gap open. Now
11089
- // that Lever 5 is inert (narrative may open pre-answer — #2588), liveness
11090
- // remains the natural open for 0-tool pre-answer turns that are silent.
11091
- // The sticky-latch (lever 1) still gates it in the drain.
11092
- // PR-4a: routed through the emission-authority façade (no-op delegate).
11093
- ea.openOrEditCard('liveness', () => {
11094
- turn.activityInFlight = drainActivitySummary(turn, 'liveness')
11095
- })
11096
- }
11097
- })
11168
+ openLivenessFeedIfDue(turn)
11098
11169
  return
11099
11170
  }
11100
11171
 
@@ -11328,6 +11399,15 @@ function handleSessionEvent(ev: SessionEvent): void {
11328
11399
  // the SAME statusKey the ctor's façade was constructed with just above.
11329
11400
  setCurrentTurn(next, statusKey(ev.chatId, enqThreadIdNum))
11330
11401
  markIdleActivity() // any turn start (main session) is activity — re-arm idle clear
11402
+ // Early-open the "Working…" liveness card at turn start so narration /
11403
+ // thinking emitted BEFORE the first tool surfaces within ~a second
11404
+ // instead of after the old 12 s threshold (the dead-air gap). Fires the
11405
+ // SAME `openLivenessFeedIfDue` the 6 s heartbeat uses — a no-op if a
11406
+ // tool/narrative already opened the card, and gated by `mayOpenActivityCard`
11407
+ // (lever 1/4) so it never opens below a delivered answer. Scoped to real
11408
+ // turns by construction: only the `enqueue` lifecycle event reaches here,
11409
+ // and anonymous one-shot hook clients (recall.py) never emit it.
11410
+ scheduleEarlyLivenessOpen(next)
11331
11411
  // Status-surface observability: one line at every turn SET so a later
11332
11412
  // dark card is traceable to which turn/topic key it belonged to.
11333
11413
  process.stderr.write(
@@ -11701,6 +11781,15 @@ function handleSessionEvent(ev: SessionEvent): void {
11701
11781
  // (no flash). The draft transport is permanently retired — both modes
11702
11782
  // use sendMessage + editMessageText for any message that does open.
11703
11783
  minInitialChars: ANSWER_LANE.minInitialChars,
11784
+ // Render raw assistant transcript markdown → Telegram HTML before
11785
+ // any parse_mode:'HTML' send/edit, matching every other outbound
11786
+ // lane (handleStreamReply, the reply handler, the turn-flush
11787
+ // backstop, handlePtyPartial). Without this the answer-stream lane
11788
+ // shipped raw text — `**bold**` arrived as literal asterisks and
11789
+ // agent narration read unformatted (the answer-stream-raw-markdown
11790
+ // bug). Injected as a dependency (same pattern as the PTY partial
11791
+ // handler's `renderText`) so answer-stream.ts stays format-free.
11792
+ renderText: (text: string) => sanitizeTelegramHtml(markdownToHtml(text)),
11704
11793
  // #1075: route through robustApiCall so flood-wait,
11705
11794
  // benign-400, and THREAD_NOT_FOUND are handled uniformly
11706
11795
  // instead of crashing the answer-stream loop on a deleted
@@ -22819,8 +22908,9 @@ async function shutdown(signal: string): Promise<void> {
22819
22908
 
22820
22909
  for (const iv of [...typingIntervals.values()]) clearInterval(iv)
22821
22910
  typingIntervals.clear()
22822
- for (const iv of [...turnTypingIntervals.values()]) clearInterval(iv)
22823
- turnTypingIntervals.clear()
22911
+ turnTypingLoop.stopAll()
22912
+ for (const t of [...earlyLivenessOpenTimers.values()]) clearTimeout(t)
22913
+ earlyLivenessOpenTimers.clear()
22824
22914
  for (const t of [...typingRetryTimers.values()]) clearTimeout(t)
22825
22915
  typingRetryTimers.clear()
22826
22916
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Turn-level `typing…` indicator — the second "something is happening" signal.
3
+ *
4
+ * A person you message shows as typing the WHOLE time they compose, not just
5
+ * the split-second a message is transmitted. Telegram's `sendChatAction('typing')`
6
+ * auto-expires after ~5 s, so a single one-shot ping on inbound leaves the chat
7
+ * dark after 5 s for any turn that thinks or reads a file before replying — the
8
+ * exact black-box gap this closes. We hold a continuous `typing…` for the whole
9
+ * turn by re-firing the action every ~4 s, and stop cleanly at the canonical
10
+ * turn-end. It runs ALONGSIDE the activity card (the early-card-open is the
11
+ * other signal): both start at turn enqueue and stop at turn-end, so the two
12
+ * "alive" signals start and stop together.
13
+ *
14
+ * This is the SAME mechanism as the tool-use `typingIntervals` (`typing-wrap.ts`)
15
+ * but on a DELIBERATELY SEPARATE interval map: if the turn loop shared that map,
16
+ * a mid-turn reply's `finally { stopTypingLoop }` would kill it and the chat
17
+ * would go dark for the rest of the turn. A dedicated map makes the turn loop
18
+ * structurally immune to those stops — only `stop` (the canonical turn-end)
19
+ * clears it. The redundant `typing` pings while a reply is mid-flight are
20
+ * harmless (same action, and `sendChatAction` is cheap).
21
+ *
22
+ * Extracted into a factory so the lifecycle (fires on start, refreshes on the
23
+ * interval, stops on turn-end, NEVER leaks a refresh interval after the turn
24
+ * completes) has its own unit test (`tests/turn-typing-loop.test.ts`) without
25
+ * spinning up the whole gateway or the Telegram bot API. The gateway injects
26
+ * the real `sendChatAction` + the chat-key function; tests inject spies + fake
27
+ * timers.
28
+ */
29
+
30
+ export interface TurnTypingLoopDeps {
31
+ /** Fire one `typing` chat action for (chatId, threadId). Errors are the
32
+ * caller's concern (the gateway swallows them); the loop never throws. */
33
+ sendChatAction: (chatId: string, threadId: number | null) => void
34
+ /** Canonical chat:thread key (the gateway's `chatKey`), so a per-topic turn
35
+ * loop is isolated from a sibling topic on the same chat. */
36
+ chatKey: (chatId: string, threadId: number | null) => string
37
+ /** Refresh cadence in ms. Telegram's `typing` auto-expires after ~5 s, so the
38
+ * default 4 s keeps it continuously lit. Override for tests. */
39
+ refreshMs?: number
40
+ }
41
+
42
+ export interface TurnTypingLoop {
43
+ /** Start (or restart) the turn-long `typing…` loop for a chat/thread. Fires
44
+ * one action immediately, then every `refreshMs`. Idempotent re-start: a
45
+ * prior loop on the same key is stopped first, so this never leaks. */
46
+ start: (chatId: string, threadId?: number | null) => void
47
+ /** Stop the loop for a chat/thread and clear its interval. Idempotent — a
48
+ * no-op when no loop is registered. The single owner of the stop is the
49
+ * gateway's canonical turn-end. */
50
+ stop: (chatId: string, threadId?: number | null) => void
51
+ /** Stop EVERY live loop and clear the map — the gateway's shutdown-drain
52
+ * cleanup (mirrors the other interval-map clears there). */
53
+ stopAll: () => void
54
+ /** Test/observability: how many loops are currently live (0 ⇒ none leaked). */
55
+ activeCount: () => number
56
+ }
57
+
58
+ /**
59
+ * Build a turn-typing loop over injected deps. The interval map is private to
60
+ * the returned closure — the SEPARATE map that makes the turn loop immune to
61
+ * the tool-use typing stops.
62
+ */
63
+ export function createTurnTypingLoop(deps: TurnTypingLoopDeps): TurnTypingLoop {
64
+ const refreshMs = deps.refreshMs ?? 4000
65
+ const intervals = new Map<string, ReturnType<typeof setInterval>>()
66
+
67
+ function stop(chatId: string, threadId: number | null = null): void {
68
+ const key = deps.chatKey(chatId, threadId)
69
+ const iv = intervals.get(key)
70
+ if (iv != null) {
71
+ clearInterval(iv)
72
+ intervals.delete(key)
73
+ }
74
+ }
75
+
76
+ function start(chatId: string, threadId: number | null = null): void {
77
+ // Restart-safe: stop any prior loop on this key first so a re-start never
78
+ // leaks a second interval (the self-heal when an abnormal abort skipped the
79
+ // turn-end stop — the next turn's start clears the stray loop).
80
+ stop(chatId, threadId)
81
+ const key = deps.chatKey(chatId, threadId)
82
+ const send = () => deps.sendChatAction(chatId, threadId)
83
+ send() // fire immediately so "typing…" lands ~instantly, not after refreshMs
84
+ const iv = setInterval(send, refreshMs)
85
+ // unref so a live loop never holds the process open (mirrors the gateway's
86
+ // other interval timers). Guarded for environments without unref.
87
+ ;(iv as { unref?: () => void }).unref?.()
88
+ intervals.set(key, iv)
89
+ }
90
+
91
+ function stopAll(): void {
92
+ for (const iv of [...intervals.values()]) clearInterval(iv)
93
+ intervals.clear()
94
+ }
95
+
96
+ return {
97
+ start,
98
+ stop,
99
+ stopAll,
100
+ activeCount: () => intervals.size,
101
+ }
102
+ }