switchroom 0.15.45 → 0.16.5

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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +56 -15
  2. package/dist/auth-broker/index.js +383 -97
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +7 -4
  5. package/dist/cli/notion-write-pretool.mjs +35 -4
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/switchroom.js +2894 -841
  9. package/dist/host-control/main.js +2685 -207
  10. package/dist/vault/approvals/kernel-server.js +7453 -7413
  11. package/dist/vault/broker/server.js +11428 -11388
  12. package/examples/minimal.yaml +1 -0
  13. package/examples/switchroom.yaml +1 -0
  14. package/package.json +3 -3
  15. package/profiles/_base/start.sh.hbs +97 -1
  16. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  17. package/profiles/default/CLAUDE.md.hbs +0 -19
  18. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  19. package/telegram-plugin/answer-stream-flag.ts +12 -49
  20. package/telegram-plugin/answer-stream.ts +5 -150
  21. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  22. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  23. package/telegram-plugin/context-exhaustion.ts +12 -0
  24. package/telegram-plugin/demo-mask.ts +154 -0
  25. package/telegram-plugin/dist/bridge/bridge.js +55 -12
  26. package/telegram-plugin/dist/gateway/gateway.js +2938 -977
  27. package/telegram-plugin/dist/server.js +55 -12
  28. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  29. package/telegram-plugin/draft-stream.ts +47 -410
  30. package/telegram-plugin/final-answer-detect.ts +17 -12
  31. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  32. package/telegram-plugin/format.ts +56 -19
  33. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  34. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  35. package/telegram-plugin/gateway/auth-command.ts +70 -14
  36. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  37. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  38. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  39. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  40. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  41. package/telegram-plugin/gateway/effort-command.ts +8 -3
  42. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  43. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  44. package/telegram-plugin/gateway/gateway.ts +1857 -292
  45. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  46. package/telegram-plugin/gateway/model-command.ts +115 -4
  47. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  48. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  49. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  50. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  51. package/telegram-plugin/history.ts +33 -11
  52. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  53. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  54. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  55. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  56. package/telegram-plugin/issues-card.ts +4 -0
  57. package/telegram-plugin/model-unavailable.ts +124 -0
  58. package/telegram-plugin/narrative-dedup.ts +69 -0
  59. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  60. package/telegram-plugin/package.json +3 -3
  61. package/telegram-plugin/pending-work-progress.ts +12 -0
  62. package/telegram-plugin/permission-rule.ts +32 -5
  63. package/telegram-plugin/permission-title.ts +152 -9
  64. package/telegram-plugin/quota-check.ts +13 -0
  65. package/telegram-plugin/quota-watch.ts +135 -7
  66. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  67. package/telegram-plugin/registry/turns-schema.ts +9 -0
  68. package/telegram-plugin/runtime-metrics.ts +13 -0
  69. package/telegram-plugin/session-tail.ts +96 -11
  70. package/telegram-plugin/silence-poke.ts +170 -24
  71. package/telegram-plugin/slot-banner-driver.ts +3 -0
  72. package/telegram-plugin/status-no-truncate.ts +44 -0
  73. package/telegram-plugin/status-reactions.ts +20 -3
  74. package/telegram-plugin/stream-controller.ts +4 -23
  75. package/telegram-plugin/stream-reply-handler.ts +6 -24
  76. package/telegram-plugin/streaming-metrics.ts +91 -0
  77. package/telegram-plugin/subagent-watcher.ts +212 -66
  78. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  79. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  80. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  82. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  83. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  84. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  87. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  88. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  89. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  90. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  91. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  92. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  93. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  94. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  95. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  96. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  97. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  98. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  99. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  100. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  101. package/telegram-plugin/tests/history.test.ts +60 -0
  102. package/telegram-plugin/tests/model-command.test.ts +134 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -48,6 +48,12 @@
48
48
  * pacing prompt + draft still apply; only the framework safety net is off.
49
49
  */
50
50
 
51
+ import {
52
+ decideMidTurnFloor,
53
+ midTurnFloorEnabled,
54
+ type LoopRole,
55
+ } from './turn-liveness-floor.js'
56
+
51
57
  /** #1292: snapshot of an in-flight tool call, surfaced in the 300s
52
58
  * framework-fallback message so the user sees the actual observable
53
59
  * ("running Grep \"foo\" for 4m") instead of the dishonest generic
@@ -73,6 +79,10 @@ export interface SilencePokeState {
73
79
  lastThinkingAt: number | null
74
80
  /** True once the 300s framework fallback has fired this turn. */
75
81
  fallbackFired: boolean
82
+ /** #2527: true once the mid-turn liveness floor has fired this turn.
83
+ * Independent of `fallbackFired` — the floor is the early (45s) quiet
84
+ * beat, the fallback the late (300s) loud unwedge. Fire-once each. */
85
+ floorFired: boolean
76
86
  /** #1292: in-flight tool calls keyed by toolUseId. Populated by
77
87
  * `noteToolStart` on every parent-agent `tool_use` event the gateway
78
88
  * sees and drained by `noteToolEnd` on the matching `tool_result`.
@@ -99,6 +109,14 @@ export interface ThresholdsMs {
99
109
  * defer is on; defaults to no ceiling (Infinity) when omitted.
100
110
  */
101
111
  fallbackHardCeiling?: number
112
+ /**
113
+ * #2527 — mid-turn liveness floor threshold. After this much busy-silence
114
+ * on a `user` turn that hasn't delivered a substantive answer, the floor
115
+ * fires ONE quiet (no-ping) interim so the user isn't left staring at the
116
+ * ambient 👀. Strictly below `fallback` (which owns the beat above it).
117
+ * Omitted (undefined) disables the floor entirely.
118
+ */
119
+ floor?: number
102
120
  }
103
121
 
104
122
  export const DEFAULT_THRESHOLDS: ThresholdsMs = {
@@ -127,8 +145,26 @@ export interface FrameworkFallbackContext {
127
145
  inFlightTools: ToolSnapshot[]
128
146
  }
129
147
 
148
+ /**
149
+ * #2527 — context handed to the gateway when the mid-turn floor fires. The
150
+ * gateway formats the honest text (from `inFlightTools`) and sends it through
151
+ * the SAME path a model reply takes — no parallel send. Mirrors
152
+ * `FrameworkFallbackContext` minus the wedge semantics: the floor never
153
+ * unwedges the turn, it just speaks.
154
+ */
155
+ export interface MidTurnFloorContext {
156
+ key: string
157
+ chatId: string
158
+ threadId: number | null
159
+ silenceMs: number
160
+ inFlightTools: ToolSnapshot[]
161
+ /** True when fired by a user "Status?" mid-turn inbound rather than the timer. */
162
+ forced: boolean
163
+ }
164
+
130
165
  export type SilencePokeMetric =
131
166
  | { kind: 'silence_fallback_sent'; key: string; fallback_kind: 'working' | 'thinking'; silence_ms: number }
167
+ | { kind: 'mid_turn_floor'; key: string; silence_ms: number; forced: boolean; decision: 'fire' | string }
132
168
 
133
169
  export interface SilencePokeDeps {
134
170
  /** Called when the 300s fallback fires. Caller sends the user-visible
@@ -141,20 +177,40 @@ export interface SilencePokeDeps {
141
177
  /** Poll interval (tests). */
142
178
  pollIntervalMs?: number
143
179
  /**
144
- * Fix A when true, the 300s framework fallback is DEFERRED while a parent
145
- * tool is genuinely in flight (`inFlightTools` non-empty): the agent is
146
- * demonstrably working, and since #2162 the live activity feed shows that
147
- * work, so nulling `currentTurn` (which the fallback does) would darken a feed
148
- * the user is actively watching. The defer is bounded by
149
- * `thresholdsMs.fallbackHardCeiling` so a hung-mid-tool turn still unwedges; a
150
- * turn with NO in-flight tool fires at the base threshold exactly as before.
151
- * Default false (legacy behaviour) — enable per-agent to canary.
180
+ * Feed-survival predicate callback. When provided, the 300s framework
181
+ * fallback is DEFERRED while this function returns true: the agent is
182
+ * demonstrably working (in-flight tool, detached background process, or a
183
+ * human-wait tool like ask_user), and since #2162 the live activity feed
184
+ * shows that work, so nulling `currentTurn` would darken a feed the user is
185
+ * actively watching. The defer is bounded by `thresholdsMs.fallbackHardCeiling`
186
+ * so a hung-or-missing-work-signal turn still unwedges eventually.
152
187
  *
153
- * A crashed agent is recovered independently by the bridge-disconnect sweep
154
- * (`onDanglingTurnsSwept`), so deferring here does not reintroduce the #1556
155
- * dangling-turn wedge for the crash case.
188
+ * This supersedes `deferFallbackWhileToolInFlight`. When present it is ALWAYS
189
+ * consulted (no extra env flag required). Set SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0
190
+ * in the environment to force-disable the defer even when this callback is wired.
191
+ */
192
+ isLegitimatelyWorking?: (key: string) => boolean
193
+ /**
194
+ * Legacy boolean flag — honoured when `isLegitimatelyWorking` is absent.
195
+ * When true, the 300s fallback is deferred while `inFlightTools` is non-empty,
196
+ * bounded by `thresholdsMs.fallbackHardCeiling`.
197
+ * @deprecated Prefer `isLegitimatelyWorking` which covers detached work and
198
+ * human-wait tools in addition to foreground in-flight tool calls.
156
199
  */
157
200
  deferFallbackWhileToolInFlight?: boolean
201
+ /**
202
+ * #2527 — called when the mid-turn liveness floor fires. The gateway sends
203
+ * the honest "still on it" interim through the shared reply path. Optional:
204
+ * when absent the floor never fires (back-compat for test harnesses).
205
+ */
206
+ onMidTurnFloor?: (ctx: MidTurnFloorContext) => Promise<void> | void
207
+ /**
208
+ * #2527 — the gateway-owned half of the floor decision: the turn's loop
209
+ * role and whether a substantive answer has already landed. silence-poke
210
+ * owns the timing/working/fire-once half; the pure `decideMidTurnFloor`
211
+ * combines both. Returns null when there is no live turn for `key`.
212
+ */
213
+ floorState?: (key: string) => { role: LoopRole; finalAnswerDelivered: boolean } | null
158
214
  }
159
215
 
160
216
  const state = new Map<string, SilencePokeState>()
@@ -180,6 +236,7 @@ export function startTurn(key: string, now: number): void {
180
236
  lastOutboundAt: null,
181
237
  lastThinkingAt: null,
182
238
  fallbackFired: false,
239
+ floorFired: false,
183
240
  inFlightTools: new Map(),
184
241
  })
185
242
  }
@@ -409,6 +466,76 @@ function truncateLabel(label: string): string {
409
466
  return label.slice(0, MAX - 1) + '…'
410
467
  }
411
468
 
469
+ /** Snapshot in-flight tools sorted longest-running first — for the honest
470
+ * floor/fallback message body. */
471
+ function snapshotInFlight(s: SilencePokeState, now: number): ToolSnapshot[] {
472
+ return Array.from(s.inFlightTools.values())
473
+ .sort((a, b) => a.startedAt - b.startedAt)
474
+ .map((t) => ({ name: t.name, label: t.label, durationMs: now - t.startedAt }))
475
+ }
476
+
477
+ /**
478
+ * #2527 — evaluate and (if eligible) fire the mid-turn liveness floor for one
479
+ * turn. silence-poke owns the timing/working/fire-once half; the gateway
480
+ * provides the role + delivery half via `floorState`; the pure
481
+ * `decideMidTurnFloor` combines them so the policy lives in one tested place.
482
+ * `forced=true` is a user "Status?" poke (bypasses timing + working).
483
+ */
484
+ function tryMidTurnFloor(key: string, s: SilencePokeState, now: number, forced: boolean): void {
485
+ if (activeDeps == null) return
486
+ const { onMidTurnFloor, floorState, isLegitimatelyWorking } = activeDeps
487
+ if (onMidTurnFloor == null || floorState == null) return
488
+ const thresholds = activeDeps.thresholdsMs ?? DEFAULT_THRESHOLDS
489
+ if (thresholds.floor == null) return
490
+ const fs = floorState(key)
491
+ if (fs == null) return
492
+ const silence = now - (s.lastOutboundAt ?? s.turnStartedAt)
493
+ if (silence < 0) return
494
+ const decision = decideMidTurnFloor({
495
+ enabled: midTurnFloorEnabled(),
496
+ role: fs.role,
497
+ finalAnswerDelivered: fs.finalAnswerDelivered,
498
+ silenceMs: silence,
499
+ floorThresholdMs: thresholds.floor,
500
+ fallbackThresholdMs: thresholds.fallback,
501
+ legitimatelyWorking: isLegitimatelyWorking?.(key) ?? false,
502
+ alreadyFired: s.floorFired,
503
+ force: forced,
504
+ })
505
+ if (decision.kind !== 'fire') {
506
+ // Per-tick skips are noise; only surface a declined FORCED poke (the
507
+ // user asked "Status?" and we chose not to speak — worth seeing).
508
+ if (forced) {
509
+ activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: decision.reason })
510
+ }
511
+ return
512
+ }
513
+ s.floorFired = true
514
+ activeDeps.emitMetric({ kind: 'mid_turn_floor', key, silence_ms: silence, forced, decision: 'fire' })
515
+ const { chatId, threadId } = parseKey(key)
516
+ try {
517
+ const r = onMidTurnFloor({ key, chatId, threadId, silenceMs: silence, inFlightTools: snapshotInFlight(s, now), forced })
518
+ if (r != null && typeof (r as Promise<void>).catch === 'function') {
519
+ ;(r as Promise<void>).catch((err) => {
520
+ process.stderr.write(`silence-poke: mid-turn floor handler rejected: ${err}\n`)
521
+ })
522
+ }
523
+ } catch (err) {
524
+ process.stderr.write(`silence-poke: mid-turn floor handler threw: ${err}\n`)
525
+ }
526
+ }
527
+
528
+ /**
529
+ * #2527 — fire the mid-turn floor immediately for `key` (a user "Status?"
530
+ * mid-turn inbound landed during a silent stretch). No-op if there is no
531
+ * live turn for the key or the floor is ineligible/already-fired.
532
+ */
533
+ export function pokeFloorNow(key: string, now: number): void {
534
+ const s = state.get(key)
535
+ if (s == null) return
536
+ tryMidTurnFloor(key, s, now, true)
537
+ }
538
+
412
539
  /**
413
540
  * Internal tick — iterates active states and fires the 300s framework
414
541
  * fallback (which the gateway turns into a user-visible message + an
@@ -423,20 +550,39 @@ function tick(now: number): void {
423
550
  const silence = now - zeroAt
424
551
  if (silence < 0) continue
425
552
 
553
+ // #2527 — the early, quiet mid-turn liveness beat (below the fallback
554
+ // window). Evaluated every tick; fires at most once per turn.
555
+ tryMidTurnFloor(key, s, now, false)
556
+
426
557
  if (!s.fallbackFired && silence >= thresholds.fallback) {
427
- // Fix A: defer the unwedge while a parent tool is genuinely in flight —
428
- // the agent is demonstrably working and the live activity feed is showing
429
- // it, so firing here (which nulls currentTurn) would darken that feed
430
- // mid-work. Bounded by the hard ceiling so a hung-mid-tool turn still
431
- // unwedges. `continue` WITHOUT setting fallbackFired so the next tick
432
- // re-checks — once the tool ends and the turn stays silent past the base
433
- // threshold, or the ceiling is crossed, it fires normally.
434
- if (
435
- activeDeps.deferFallbackWhileToolInFlight === true &&
436
- s.inFlightTools.size > 0 &&
437
- silence < (thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY)
438
- ) {
439
- continue
558
+ // Feed-survival defer: hold back the unwedge while the agent is
559
+ // demonstrably working an in-flight tool, a detached background process,
560
+ // or a human-wait tool (ask_user). Since #2162 the live activity feed
561
+ // renders that work, so nulling currentTurn would darken a feed the user
562
+ // is actively watching. Bounded by fallbackHardCeiling so a
563
+ // hung-or-leaked-signal turn still unwedges eventually.
564
+ //
565
+ // Two defer paths (tried in priority order):
566
+ // 1. `isLegitimatelyWorking(key)` new single source of truth covering
567
+ // foreground in-flight tools, detached background work, and human-wait
568
+ // tools. Active by default when the callback is wired; force-disabled
569
+ // by SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS=0.
570
+ // 2. Legacy `deferFallbackWhileToolInFlight` boolean — covers only
571
+ // `inFlightTools.size > 0`; kept for test fixtures that set it
572
+ // directly without wiring the callback.
573
+ //
574
+ // In both cases: `continue` WITHOUT setting fallbackFired so the next
575
+ // tick re-checks. Once the work signal clears and the turn stays silent
576
+ // past the base threshold, or the ceiling is crossed, the fallback fires.
577
+ const ceiling = thresholds.fallbackHardCeiling ?? Number.POSITIVE_INFINITY
578
+ const underCeiling = silence < ceiling
579
+ if (underCeiling) {
580
+ const forceDisable = process.env.SWITCHROOM_SILENCE_DEFER_INFLIGHT_TOOLS === '0'
581
+ if (!forceDisable && activeDeps.isLegitimatelyWorking != null) {
582
+ if (activeDeps.isLegitimatelyWorking(key)) continue
583
+ } else if (!forceDisable && activeDeps.deferFallbackWhileToolInFlight === true && s.inFlightTools.size > 0) {
584
+ continue
585
+ }
440
586
  }
441
587
  s.fallbackFired = true
442
588
  const { chatId, threadId } = parseKey(key)
@@ -110,6 +110,9 @@ export async function refreshBanner(
110
110
  sent = await args.bot.api.sendMessage(args.ownerChatId, action.text, {
111
111
  parse_mode: 'HTML',
112
112
  link_preview_options: { is_disabled: true },
113
+ // OAuth slot banner is a status notice — silence the open ping.
114
+ // (the pin below is already silent; the edit path doesn't ping.)
115
+ disable_notification: true,
113
116
  });
114
117
  } catch (err) {
115
118
  args.onError?.('pin', err);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Status-card shared constants.
3
+ *
4
+ * Both status surfaces — the main-session agent activity card
5
+ * (`tool-activity-summary.ts`) and the background-worker activity feed
6
+ * (`worker-activity-feed.ts`) — render through the single
7
+ * `renderStatusCard` primitive in `tool-activity-summary.ts`. This module
8
+ * holds the tuning constants that primitive (and its internal helpers)
9
+ * read, so a forked renderer never re-derives them.
10
+ *
11
+ * The former `SWITCHROOM_STATUS_NO_TRUNCATE` feature flag was retired:
12
+ * rolling-window-with-char-budget is now the only behaviour. The per-line
13
+ * cap (`STATUS_LINE_MAX`) and rolling window (`STATUS_ROLLING_LINES`) apply
14
+ * universally on BOTH surfaces; the total char budget
15
+ * (`STATUS_CARD_CHAR_BUDGET`) is the wire-limit backstop.
16
+ */
17
+
18
+ /**
19
+ * Number of trailing narrative/step lines shown in the rolling window.
20
+ * The feed is a fixed-height rolling window: oldest drops off as new arrive.
21
+ * Overflow surfaces a `+N earlier…` header on BOTH surfaces.
22
+ */
23
+ export const STATUS_ROLLING_LINES = 5
24
+
25
+ /**
26
+ * Per-line character cap, applied to every step + child step on BOTH
27
+ * surfaces before HTML-escaping (clip raw → escape last). A line longer
28
+ * than this is truncated with a trailing `…`.
29
+ */
30
+ export const STATUS_LINE_MAX = 200
31
+
32
+ /**
33
+ * The safe char budget for a rendered Telegram status card. Telegram's hard
34
+ * cap is 4096; we use 4000 to leave 96 chars of headroom for HTML framing,
35
+ * emoji, and escape expansion — matching the convention in
36
+ * pending-work-progress.ts (TELEGRAM_MSG_CAP = 4000).
37
+ *
38
+ * With STATUS_ROLLING_LINES=5 lines each ≤ STATUS_LINE_MAX this backstop
39
+ * effectively never fires in practice, but is kept as a wire-limit safety net.
40
+ */
41
+ export const STATUS_CARD_CHAR_BUDGET = 4000
42
+
43
+ /** Indent marker for a nested (foreground sub-agent) step line. */
44
+ export const NESTED_PREFIX = ' ↳ '
@@ -55,6 +55,7 @@ export type ReactionState =
55
55
  | 'compacting'
56
56
  | 'awaiting'
57
57
  | 'done'
58
+ | 'undelivered'
58
59
  | 'error'
59
60
  | 'stallSoft'
60
61
  | 'stallHard'
@@ -80,7 +81,11 @@ export const REACTION_VARIANTS: Record<ReactionState, string[]> = {
80
81
  web: ['⚡', '🤔', '👌'], // WORKING: lookup in motion
81
82
  compacting:['✍', '🤔', '👀'],
82
83
  awaiting: ['🙏', '🤔', '👀'], // BLOCKED ON HUMAN: parked on a permission card
83
- done: ['👍', '💯', '🎉'], // FINISHED: turn_end fired
84
+ done: ['👍', '💯', '🎉'], // FINISHED: turn_end delivered an answer
85
+ // #2527 — FINISHED but the user turn produced NO answer. A gentle,
86
+ // non-celebratory terminal so the ambient signal never reads as "done"
87
+ // over an undelivered turn. The silent-end fallback text carries the why.
88
+ undelivered:['😐', '🤷', '🤔'],
84
89
  error: ['😱', '😨', '🤯'], // NON-TERMINAL — recovery allowed
85
90
  stallSoft: ['🥱', '😴', '🤔'],
86
91
  stallHard: ['😨', '🤯', '😱'],
@@ -103,7 +108,7 @@ export function resolveToolReactionState(toolName: string): ReactionState {
103
108
  }
104
109
 
105
110
  /** Reason passed to `finalize()` — selects the terminal emoji. */
106
- export type FinalizeReason = 'done' | 'error'
111
+ export type FinalizeReason = 'done' | 'undelivered' | 'error'
107
112
 
108
113
  /** Configuration knobs the controller respects. */
109
114
  export interface StatusReactionConfig {
@@ -115,6 +120,14 @@ export interface StatusReactionConfig {
115
120
  stallHardMs?: number
116
121
  /** Optional logger for debugging — receives a single string per event. */
117
122
  log?: (msg: string) => void
123
+ /**
124
+ * Optional structured callback fired on every emoji transition (after the
125
+ * API call succeeds). Used by the gateway to emit `status_reaction_transition`
126
+ * streaming-metrics events for #2527 observability. Kept out of the main
127
+ * `log` callback so callers that only want the human-readable log string
128
+ * don't need to parse it.
129
+ */
130
+ onTransition?: (emoji: string) => void
118
131
  }
119
132
 
120
133
  /**
@@ -152,6 +165,7 @@ export class StatusReactionController {
152
165
  private readonly stallSoftMs: number
153
166
  private readonly stallHardMs: number
154
167
  private readonly log?: (msg: string) => void
168
+ private readonly onTransition?: (emoji: string) => void
155
169
 
156
170
  constructor(
157
171
  private readonly emit: ReactionEmitter,
@@ -163,6 +177,7 @@ export class StatusReactionController {
163
177
  this.stallSoftMs = config.stallSoftMs ?? 30000
164
178
  this.stallHardMs = config.stallHardMs ?? 90000
165
179
  this.log = config.log
180
+ this.onTransition = config.onTransition
166
181
  }
167
182
 
168
183
  /** 👀 — message received and queued for processing. Bypasses debounce. */
@@ -222,7 +237,8 @@ export class StatusReactionController {
222
237
  * lands promptly. Subsequent calls are no-ops.
223
238
  */
224
239
  finalize(reason: FinalizeReason = 'done'): void {
225
- const state: ReactionState = reason === 'error' ? 'error' : 'done'
240
+ const state: ReactionState =
241
+ reason === 'error' ? 'error' : reason === 'undelivered' ? 'undelivered' : 'done'
226
242
  this.finishWithState(state)
227
243
  }
228
244
 
@@ -348,6 +364,7 @@ export class StatusReactionController {
348
364
  this.currentEmoji = emoji
349
365
  if (this.pendingEmoji === emoji) this.pendingEmoji = null
350
366
  this.log?.(`reaction → ${emoji}`)
367
+ this.onTransition?.(emoji)
351
368
  } catch (err) {
352
369
  this.log?.(`reaction emit failed (${emoji}): ${(err as Error).message}`)
353
370
  }
@@ -16,7 +16,7 @@
16
16
  * entire server.ts top-level initialization.
17
17
  */
18
18
 
19
- import { createDraftStream, type DraftStreamHandle, type StreamDraftFn } from './draft-stream.js'
19
+ import { createDraftStream, type DraftStreamHandle } from './draft-stream.js'
20
20
  import { htmlToPlainText } from './html-sanitize.js'
21
21
 
22
22
  /**
@@ -152,30 +152,15 @@ export interface StreamControllerConfig {
152
152
  */
153
153
  log?: (msg: string) => void
154
154
  /**
155
- * Optional warning logger. Used for transport fallback notices.
155
+ * Optional warning logger. Used for fallback notices.
156
156
  */
157
157
  warn?: (msg: string) => void
158
- /**
159
- * Transport selector passed to createDraftStream.
160
- * - "auto" (default): use draft transport for DMs only
161
- * - "draft": always prefer draft (if sendMessageDraft is available)
162
- * - "message": always use sendMessage/editMessageText
163
- *
164
- * The gateway forces "message" for forum topics (threads), since
165
- * sendMessageDraft does not support threaded chats.
166
- */
167
- previewTransport?: 'auto' | 'message' | 'draft'
168
158
  /**
169
159
  * True when the chat is a private DM. Passed to createDraftStream so
170
- * "auto" transport knows whether to activate draft.
160
+ * the throttle default (400 ms for DMs vs 1000 ms for groups) is applied
161
+ * correctly when no explicit throttleMs is set.
171
162
  */
172
163
  isPrivateChat?: boolean
173
- /**
174
- * sendMessageDraft callback. When provided (and transport allows it),
175
- * intermediate stream updates use the draft API. On finalize(), a real
176
- * sendMessage is posted for push notification and the draft is cleared.
177
- */
178
- sendMessageDraft?: StreamDraftFn
179
164
  /**
180
165
  * If set, the controller is initialized as if a previous send had
181
166
  * landed with this `message_id`. The first `update()` invokes
@@ -214,9 +199,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
214
199
  quoteText,
215
200
  protectContent,
216
201
  replyMarkup,
217
- previewTransport,
218
202
  isPrivateChat,
219
- sendMessageDraft,
220
203
  initialMessageId,
221
204
  } = cfg
222
205
 
@@ -314,9 +297,7 @@ export function createStreamController(cfg: StreamControllerConfig): DraftStream
314
297
  ...(idleMs != null ? { idleMs } : {}),
315
298
  ...(log != null ? { log } : {}),
316
299
  ...(warn != null ? { warn } : {}),
317
- ...(previewTransport != null ? { previewTransport } : {}),
318
300
  ...(isPrivateChat != null ? { isPrivateChat } : {}),
319
- ...(sendMessageDraft != null ? { sendMessageDraft } : {}),
320
301
  ...(initialMessageId != null ? { initialMessageId } : {}),
321
302
  chatId,
322
303
  },
@@ -16,7 +16,7 @@
16
16
  * wraps into an MCP content response.
17
17
  */
18
18
 
19
- import type { DraftStreamHandle, StreamDraftFn } from './draft-stream.js'
19
+ import type { DraftStreamHandle } from './draft-stream.js'
20
20
  import {
21
21
  createStreamController,
22
22
  type StreamBotApi,
@@ -240,13 +240,6 @@ export interface StreamReplyDeps {
240
240
  /** Error-path stderr. */
241
241
  writeError: (line: string) => void
242
242
  throttleMs?: number
243
- /**
244
- * sendMessageDraft callback. When provided, stream_reply uses the draft
245
- * API for intermediate updates (DM transport). On done=true, a real
246
- * sendMessage fires for push notification, then the draft is cleared.
247
- * Optional — omit to keep the existing sendMessage/editMessageText path.
248
- */
249
- sendMessageDraft?: StreamDraftFn
250
243
  /**
251
244
  * Idempotency hook for the duplicate-message class (issue #626).
252
245
  *
@@ -275,12 +268,12 @@ export interface StreamReplyDeps {
275
268
  }) => number | null | undefined
276
269
  /**
277
270
  * True when the current chat is a private DM. Passed to the stream
278
- * controller so "auto" transport activates draft in DMs only.
271
+ * controller so the DM throttle default (400 ms) is applied instead of
272
+ * the group default (1000 ms) when no explicit throttleMs is set.
279
273
  */
280
274
  isPrivateChat?: boolean
281
275
  /**
282
- * True when the current chat is a forum topic. Forum topics do not
283
- * support sendMessageDraft — this forces message transport.
276
+ * True when the current chat is a forum topic.
284
277
  */
285
278
  isForumTopic?: boolean
286
279
  /**
@@ -464,14 +457,6 @@ export async function handleStreamReply(
464
457
  }
465
458
  }
466
459
 
467
- // Resolve draft-transport options. Forum topics force message transport
468
- // because sendMessageDraft does not support threads.
469
- const isForumTopic = deps.isForumTopic === true
470
- const resolvedTransport: 'auto' | 'message' | 'draft' =
471
- isForumTopic || deps.sendMessageDraft == null
472
- ? 'message'
473
- : 'auto'
474
-
475
460
  // Idempotency hook (#626): if an external authority (e.g. the
476
461
  // gateway's pin manager) already knows the anchor message id for
477
462
  // this lane+turn, initialize the stream with it so the next update
@@ -504,8 +489,8 @@ export async function handleStreamReply(
504
489
  threadId,
505
490
  parseMode,
506
491
  disableLinkPreview: deps.disableLinkPreview,
507
- // PR B: pass undefined when caller didn't override, so draft-stream's
508
- // transport-aware default (300 ms draft / 1000 ms message) wins.
492
+ // Pass undefined when caller didn't override, so draft-stream's
493
+ // DM/group throttle defaults apply (400 ms DMs, 1000 ms groups).
509
494
  ...(deps.throttleMs != null ? { throttleMs: deps.throttleMs } : {}),
510
495
  retry: deps.retry,
511
496
  ...(replyToMessageId != null ? { replyToMessageId } : {}),
@@ -513,9 +498,7 @@ export async function handleStreamReply(
513
498
  ...(args.protect_content === true ? { protectContent: true } : {}),
514
499
  ...(args.disable_notification === true ? { disableNotification: true } : {}),
515
500
  ...(args.reply_markup != null ? { replyMarkup: args.reply_markup } : {}),
516
- previewTransport: resolvedTransport,
517
501
  isPrivateChat: deps.isPrivateChat === true,
518
- ...(deps.sendMessageDraft != null ? { sendMessageDraft: deps.sendMessageDraft } : {}),
519
502
  ...(initialMessageId != null ? { initialMessageId } : {}),
520
503
  onSend: (messageId, charCount) =>
521
504
  deps.logStreamingEvent({ kind: 'draft_send', chatId: chat_id, messageId, charCount }),
@@ -539,7 +522,6 @@ export async function handleStreamReply(
539
522
  || msg.startsWith('stream → edited')
540
523
  || msg.startsWith('stream → not modified')
541
524
  || msg.startsWith('stream finalized')
542
- || msg.startsWith('stream → draft')
543
525
  || msg.startsWith('stream → materialized')
544
526
  ) return
545
527
  deps.writeError(`telegram channel: stream_reply ${msg}\n`)
@@ -93,6 +93,97 @@ export type StreamingEvent =
93
93
  chatId: string
94
94
  messageId: number | undefined
95
95
  }
96
+ /**
97
+ * Emitted when maybeEarlyAckReaction fires the 👀 pre-coalesce reaction
98
+ * for a private-chat inbound. Lets operators see how often the fast-ack
99
+ * path triggers vs. the regular StatusReactionController path (#553 F2).
100
+ */
101
+ | {
102
+ kind: 'early_ack_reaction'
103
+ chatId: string
104
+ messageId: number
105
+ emoji: string
106
+ }
107
+ /**
108
+ * Emitted when a fresh StatusReactionController is installed for a new turn
109
+ * (group / non-DM path where the controller manages the whole reaction lifecycle).
110
+ */
111
+ | {
112
+ kind: 'status_reaction_install'
113
+ chatId: string
114
+ turnId: string
115
+ messageId: number
116
+ }
117
+ /**
118
+ * Emitted on every emoji transition inside a StatusReactionController.
119
+ * Lets operators trace the full queued→thinking→tool→done lifecycle and
120
+ * see how many state changes occur in a silent turn.
121
+ */
122
+ | {
123
+ kind: 'status_reaction_transition'
124
+ chatId: string
125
+ turnId: string
126
+ emoji: string
127
+ }
128
+ /**
129
+ * Emitted when StatusReactionController.finalize() / setDone() runs
130
+ * (controller disposed at turn_end or disconnect-flush). Terminal event.
131
+ */
132
+ | {
133
+ kind: 'status_reaction_dispose'
134
+ chatId: string
135
+ turnId: string
136
+ reason: 'done' | 'error' | 'disconnect' | 'undelivered'
137
+ }
138
+ /**
139
+ * Emitted when the FIRST text reply (reply or stream_reply) of a turn is
140
+ * sent to the user. `timeToFirstTextReplyMs` is the wall-clock delta from
141
+ * the inbound-received timestamp to the moment this reply tool fires.
142
+ * Issue #2527 instrumentation: reveals when a turn is reaction-only.
143
+ */
144
+ | {
145
+ kind: 'turn_reply_timing'
146
+ chatId: string
147
+ threadId: number | undefined
148
+ turnId: string
149
+ timeToFirstTextReplyMs: number
150
+ }
151
+ /**
152
+ * Emitted at turn_end when the turn produced ZERO text replies (only
153
+ * reaction-emoji transitions). This is the primary observable for the
154
+ * #2527 failure mode — the user sees only an emoji and the turn is done.
155
+ */
156
+ | {
157
+ kind: 'turn_no_reply_warn'
158
+ chatId: string
159
+ threadId: number | undefined
160
+ turnId: string
161
+ turnDurationMs: number
162
+ reactionCount: number
163
+ }
164
+ /**
165
+ * Emitted when the silence-poke framework-fallback fires and sends its
166
+ * "still working…" ping. Records the silence duration so operators can
167
+ * correlate with reaction-only turns.
168
+ */
169
+ | {
170
+ kind: 'silence_poke_fire'
171
+ chatId: string
172
+ threadId: number | undefined
173
+ silenceMs: number
174
+ fallbackKind: string
175
+ }
176
+ /**
177
+ * Emitted when the silence-poke handler short-circuits because the turn
178
+ * already ended cleanly during the silence window (the late-fire race).
179
+ */
180
+ | {
181
+ kind: 'silence_poke_skip'
182
+ chatId: string
183
+ threadId: number | undefined
184
+ silenceMs: number
185
+ skipReason: string
186
+ }
96
187
 
97
188
  /**
98
189
  * True iff the env gate is on. Re-read on every call so tests can toggle