switchroom 0.15.45 → 0.16.4

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 (149) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  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/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3158 -1178
  10. package/dist/host-control/main.js +2833 -355
  11. package/dist/vault/approvals/kernel-server.js +7479 -7439
  12. package/dist/vault/broker/server.js +11312 -11272
  13. package/examples/minimal.yaml +1 -0
  14. package/examples/switchroom.yaml +1 -0
  15. package/package.json +3 -3
  16. package/profiles/_base/start.sh.hbs +88 -1
  17. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  18. package/profiles/default/CLAUDE.md.hbs +0 -19
  19. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  20. package/telegram-plugin/answer-stream-flag.ts +12 -49
  21. package/telegram-plugin/answer-stream.ts +5 -150
  22. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  23. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  24. package/telegram-plugin/context-exhaustion.ts +12 -0
  25. package/telegram-plugin/demo-mask.ts +154 -0
  26. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  27. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  28. package/telegram-plugin/dist/server.js +215 -172
  29. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  30. package/telegram-plugin/draft-stream.ts +47 -410
  31. package/telegram-plugin/final-answer-detect.ts +17 -12
  32. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  33. package/telegram-plugin/format.ts +56 -19
  34. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  35. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  36. package/telegram-plugin/gateway/auth-command.ts +70 -14
  37. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  38. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  39. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  40. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  41. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  42. package/telegram-plugin/gateway/effort-command.ts +8 -3
  43. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  44. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  45. package/telegram-plugin/gateway/gateway.ts +1837 -291
  46. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  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-unavailable.test.ts +118 -0
  103. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  104. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  105. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  106. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  107. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  108. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  109. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  110. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  111. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  112. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  113. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  114. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  115. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  116. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  117. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  118. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  119. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  120. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  121. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  122. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  123. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  124. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  125. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  126. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  127. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  128. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  129. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  130. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  131. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  132. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  133. package/telegram-plugin/tool-activity-summary.ts +375 -58
  134. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  135. package/telegram-plugin/uat/assertions.ts +115 -0
  136. package/telegram-plugin/uat/driver.ts +68 -0
  137. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  138. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  139. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  145. package/telegram-plugin/welcome-text.ts +13 -1
  146. package/telegram-plugin/worker-activity-feed.ts +157 -82
  147. package/telegram-plugin/draft-transport.ts +0 -122
  148. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  149. package/telegram-plugin/tests/draft-transport.test.ts +0 -211
@@ -23,6 +23,30 @@ import {
23
23
  getTurnByKey,
24
24
  } from './turns-schema.js'
25
25
 
26
+ // ---------------------------------------------------------------------------
27
+ // Concurrency PRAGMAs — applySchema must arm busy_timeout so concurrent
28
+ // writers (the subagent-tracker hooks + the gateway watcher) wait-and-retry
29
+ // instead of failing with SQLITE_BUSY ("database is locked").
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('registry concurrency PRAGMAs', () => {
33
+ it('arms busy_timeout (5000ms) on every opened connection', () => {
34
+ const db = openTurnsDbInMemory()
35
+ const row = db.prepare('PRAGMA busy_timeout').get() as { timeout: number }
36
+ expect(row.timeout).toBe(5000)
37
+ db.close()
38
+ })
39
+
40
+ it('uses WAL journal mode for concurrent readers', () => {
41
+ const db = openTurnsDbInMemory()
42
+ const row = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }
43
+ // `:memory:` reports 'memory'; a file DB reports 'wal'. Either way the
44
+ // exec ran without error — the file-path open (openTurnsDb) yields 'wal'.
45
+ expect(['wal', 'memory']).toContain(String(row.journal_mode).toLowerCase())
46
+ db.close()
47
+ })
48
+ })
49
+
26
50
  // ---------------------------------------------------------------------------
27
51
  // Test 1 — empty DB
28
52
  // ---------------------------------------------------------------------------
@@ -172,6 +172,15 @@ const PHASE2_MIGRATIONS = [
172
172
  function applySchema(db: SqliteDatabase): void {
173
173
  db.exec('PRAGMA journal_mode = WAL')
174
174
  db.exec('PRAGMA synchronous = NORMAL')
175
+ // Concurrency: multiple writers contend on this registry (the PreToolUse
176
+ // subagent-tracker hook, the gateway's subagent-watcher backfill, the turns
177
+ // writer) — especially when several sub-agents dispatch at once. Without a
178
+ // busy_timeout, bun:sqlite/better-sqlite3 default to 0ms and the second
179
+ // contending write fails IMMEDIATELY with SQLITE_BUSY ("database is locked"),
180
+ // which the watcher swallows → jsonl_agent_id / parent_turn_key left NULL →
181
+ // worker card mis-routes to the operator DM + false silent-stall synthesis.
182
+ // 5s of wait-and-retry serializes the contenders instead of dropping writes.
183
+ db.exec('PRAGMA busy_timeout = 5000')
175
184
  db.exec(SCHEMA_SQL)
176
185
  // Run migrations. SQLite doesn't support "ADD COLUMN IF NOT EXISTS", so
177
186
  // we swallow the "duplicate column" error to stay idempotent on
@@ -77,6 +77,19 @@ export type RuntimeMetricEvent =
77
77
  fallback_kind: 'working' | 'thinking'
78
78
  silence_ms: number
79
79
  }
80
+ /**
81
+ * #2527 — mid-turn liveness floor decision. `decision: 'fire'` when the
82
+ * quiet "still on it" beat was sent; otherwise the machine-readable skip
83
+ * reason for a declined forced ("Status?") poke. `forced` distinguishes
84
+ * the timer beat from a user-asked one.
85
+ */
86
+ | {
87
+ kind: 'mid_turn_floor'
88
+ key: string
89
+ silence_ms: number
90
+ forced: boolean
91
+ decision: string
92
+ }
80
93
  /**
81
94
  * #1445 cross-turn pending-async ambient lifecycle. `started` fires
82
95
  * when a turn ends with a captured anchor AND a pending Agent/Task/
@@ -98,7 +98,17 @@ export type SessionEvent =
98
98
  // the lazily-flushed transcript. The draft-mirror drives off THIS, not
99
99
  // the flush-gated `tool_use`, so activity streams deterministically.
100
100
  | { kind: 'tool_label'; toolUseId: string; label: string; toolName: string }
101
- | { kind: 'text'; text: string }
101
+ // `blockIndex` = index of this text block in the assistant message's
102
+ // content[] — load-bearing: it keys the returned Map so callers emit
103
+ // events in source order. `lastInMessage` = true iff no tool_use block
104
+ // follows it in the SAME message. NOTE: `lastInMessage` is a PROJECTION
105
+ // ARTIFACT only — the current reducer-side narrative-dedup gate
106
+ // (narrative-dedup.ts) decides draft-then-send vs working-narration by
107
+ // LOOKAHEAD (the next tool_use / turn_end), NOT by reading this flag. It
108
+ // is retained as a stable projection output (pinned by the kernel test)
109
+ // and reserved for a future staging-skip optimization; do not assume the
110
+ // gate keys on it.
111
+ | { kind: 'text'; text: string; blockIndex: number; lastInMessage: boolean }
102
112
  | { kind: 'tool_result'; toolUseId: string; toolName: string | null; isError?: boolean; errorText?: string }
103
113
  | { kind: 'turn_end'; durationMs: number }
104
114
  // Multi-agent: sub-agent-scoped events. agentId is the sub-agent JSONL
@@ -106,8 +116,12 @@ export type SessionEvent =
106
116
  // as parent events; the reducer fans them out to per-sub-agent state.
107
117
  | { kind: 'sub_agent_started'; agentId: string; firstPromptText: string; subagentType?: string }
108
118
  | { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
109
- | { kind: 'sub_agent_text'; agentId: string; text: string }
110
- | { kind: 'sub_agent_narrative'; agentId: string; text: string }
119
+ // Same shared contract as the main-agent `text` kind — see its doc above
120
+ // (including the `lastInMessage` projection-artifact note). The wire-kind
121
+ // stays distinct (the gateway/watcher split is load-bearing) but the
122
+ // payload + `lastInMessage` derivation are identical so ONE shared dedup
123
+ // gate handles both tiers.
124
+ | { kind: 'sub_agent_text'; agentId: string; text: string; blockIndex: number; lastInMessage: boolean }
111
125
  | { kind: 'sub_agent_tool_result'; agentId: string; toolUseId: string; isError?: boolean; errorText?: string }
112
126
  | { kind: 'sub_agent_turn_end'; agentId: string }
113
127
  | { kind: 'sub_agent_nested_spawn'; agentId: string }
@@ -182,6 +196,49 @@ function extractToolResultErrorText(content: unknown): string {
182
196
  return ''
183
197
  }
184
198
 
199
+ /**
200
+ * THE single text→narrative projection primitive. Both projectTranscriptLine
201
+ * and projectSubagentLine derive their text events through this helper so
202
+ * main-agent, sub-agent, worker, and every other execution shape inherit
203
+ * identical text-block semantics from ONE place: empty/whitespace blocks are
204
+ * dropped, and each surviving block carries its `blockIndex` plus the
205
+ * `lastInMessage` signal (no tool_use follows it in this message). NOTE:
206
+ * `lastInMessage` is a projection artifact — the reducer-side dedup gate
207
+ * decides SHOW/SUPPRESS by lookahead, not by reading this flag (see the
208
+ * SessionEvent `text` doc); it is reserved for a future staging-skip
209
+ * optimization.
210
+ *
211
+ * `make` adapts the shared payload into the tier-specific wire kind
212
+ * (`text` vs `sub_agent_text`); the contract — what counts as a text block,
213
+ * how `lastInMessage` is computed — lives here, not in the callers.
214
+ *
215
+ * Returns a `Map<blockIndex, SessionEvent>` keyed by the text block's source
216
+ * index, NOT a flat list. This is the load-bearing design choice: the callers
217
+ * must emit thinking / tool_use / text events in SOURCE ORDER (the reducer
218
+ * pairs a preamble to the immediately-next tool_use), so they iterate
219
+ * `content` once and, at each text position, emit the precomputed event from
220
+ * this map. The kernel owns the contract; the caller owns only the ordering.
221
+ */
222
+ export function projectAssistantTextBlocks(
223
+ content: Array<Record<string, unknown>>,
224
+ make: (text: string, blockIndex: number, lastInMessage: boolean) => SessionEvent,
225
+ ): Map<number, SessionEvent> {
226
+ const out = new Map<number, SessionEvent>()
227
+ // Precompute the index of the last tool_use so each text block knows
228
+ // whether a tool_use follows it in THIS message (the draft-then-send signal).
229
+ let lastToolUseIdx = -1
230
+ content.forEach((c, i) => {
231
+ if (c.type === 'tool_use') lastToolUseIdx = i
232
+ })
233
+ content.forEach((c, i) => {
234
+ if (c.type !== 'text') return
235
+ const text = (c.text as string | undefined) ?? ''
236
+ if (text.trim().length === 0) return // drop empty/whitespace-only blocks
237
+ out.set(i, make(text, i, i > lastToolUseIdx))
238
+ })
239
+ return out
240
+ }
241
+
185
242
  /**
186
243
  * Project a single transcript line into a SessionEvent (or null if it's
187
244
  * uninteresting noise). Caller is responsible for the JSON parse — if a
@@ -218,7 +275,16 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
218
275
  const content = message?.content as Array<Record<string, unknown>> | undefined
219
276
  if (!Array.isArray(content)) return []
220
277
  const events: SessionEvent[] = []
221
- for (const c of content) {
278
+ // Text→narrative projection comes from the ONE shared kernel
279
+ // (projectAssistantTextBlocks): it owns the empty-drop + blockIndex +
280
+ // lastInMessage contract. We emit its events at their source positions
281
+ // so thinking / tool_use / text stay in source order (the reducer pairs
282
+ // a preamble to the immediately-next tool_use).
283
+ const textEvents = projectAssistantTextBlocks(
284
+ content,
285
+ (text, blockIndex, lastInMessage): SessionEvent => ({ kind: 'text', text, blockIndex, lastInMessage }),
286
+ )
287
+ content.forEach((c, i) => {
222
288
  const ct = c.type as string | undefined
223
289
  if (ct === 'thinking') {
224
290
  events.push({ kind: 'thinking' })
@@ -237,10 +303,10 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
237
303
  input: input && typeof input === 'object' ? input : undefined,
238
304
  })
239
305
  } else if (ct === 'text') {
240
- const text = (c.text as string | undefined) ?? ''
241
- events.push({ kind: 'text', text })
306
+ const ev = textEvents.get(i)
307
+ if (ev != null) events.push(ev)
242
308
  }
243
- }
309
+ })
244
310
  return events
245
311
  }
246
312
 
@@ -357,7 +423,25 @@ export function projectSubagentLine(
357
423
  const content = message?.content as Array<Record<string, unknown>> | undefined
358
424
  if (!Array.isArray(content)) return []
359
425
  const events: SessionEvent[] = []
360
- for (const c of content) {
426
+ // Text→narrative projection comes from the SAME shared kernel as the
427
+ // main agent (projectAssistantTextBlocks): one source for the empty-drop
428
+ // + blockIndex + lastInMessage contract. The `make` adapter only changes
429
+ // the wire kind to `sub_agent_text`. A nested Agent/Task tool_use still
430
+ // counts as a tool_use that follows a preceding text block — handled by
431
+ // the kernel — so a sub-agent preamble before a nested spawn is correctly
432
+ // NOT `lastInMessage`. We emit at source positions so text + tool_use
433
+ // stay in source order (the reducer pairs preamble → next tool_use).
434
+ const textEvents = projectAssistantTextBlocks(
435
+ content,
436
+ (text, blockIndex, lastInMessage): SessionEvent => ({
437
+ kind: 'sub_agent_text',
438
+ agentId,
439
+ text,
440
+ blockIndex,
441
+ lastInMessage,
442
+ }),
443
+ )
444
+ content.forEach((c, i) => {
361
445
  const ct = c.type as string | undefined
362
446
  if (ct === 'tool_use') {
363
447
  const name = (c.name as string | undefined) ?? ''
@@ -386,10 +470,11 @@ export function projectSubagentLine(
386
470
  // in the SAME assistant message must be emitted in source order
387
471
  // so the reducer consumes the preamble on the immediately-next
388
472
  // tool_use and sibling tool_uses fall back to filename/pattern.
389
- const text = (c.text as string | undefined) ?? ''
390
- events.push({ kind: 'sub_agent_text', agentId, text })
473
+ // The event itself comes from the shared kernel (textEvents above).
474
+ const ev = textEvents.get(i)
475
+ if (ev != null) events.push(ev)
391
476
  }
392
- }
477
+ })
393
478
  // Authoritative early terminal: a background `Agent` worker's JSONL on
394
479
  // claude ≥2.1.156 never writes the `system/turn_duration` line below, so
395
480
  // the watcher used to only learn the worker finished via the ~5-min
@@ -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
  }