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
@@ -34,11 +34,11 @@
34
34
 
35
35
  import {
36
36
  cleanWorkerResultParagraph,
37
- escapeHtml,
38
- formatDuration,
39
37
  stripMarkdown,
40
38
  truncate,
41
39
  } from './card-format.js'
40
+ import { STATUS_ROLLING_LINES } from './status-no-truncate.js'
41
+ import { renderStatusCard, formatFeedElapsed } from './tool-activity-summary.js'
42
42
 
43
43
  /** Worker-activity feed is ON by default; an operator opts out with
44
44
  * SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
@@ -86,103 +86,82 @@ export interface BotApiForWorkerFeed {
86
86
  ): Promise<unknown>
87
87
  }
88
88
 
89
+ /** Dispatch-time task description cap for the worker header. */
89
90
  const DESC_MAX = 80
90
- const STEP_MAX = 100
91
- const RESULT_MAX = 320
92
- /** Subtle horizontal rule between the running feed and the finished result. */
93
- const RULE = '─────'
94
- /**
95
- * How many trailing narrative lines the live feed keeps visible. The feed
96
- * grows like the main agent's answer but can't grow unbounded — Telegram
97
- * caps message length and a wall of stale lines buries the live one. Six
98
- * keeps recent context without dominating the chat.
99
- */
100
- const NARRATIVE_MAX_LINES = 6
101
91
 
102
92
  /**
103
- * Append the accumulated step feed to `lines`, mirroring the main agent's
104
- * activity card (`renderActivityFeed`): prior steps render done (`✓`, italic),
105
- * the newest renders in-progress (`→`, bold) unless `allDone`, and an overflow
106
- * header (`✓ +N earlier…`) appears when the feed exceeds NARRATIVE_MAX_LINES.
107
- * `steps` are already cleaned + escaped HTML.
108
- */
109
- function appendStepFeed(lines: string[], steps: string[], allDone: boolean): void {
110
- if (steps.length === 0) return
111
- const shown = steps.slice(-NARRATIVE_MAX_LINES)
112
- const hidden = steps.length - shown.length
113
- if (hidden > 0) lines.push(`<i>✓ +${hidden} earlier…</i>`)
114
- const lastIdx = shown.length - 1
115
- shown.forEach((s, i) => {
116
- lines.push(!allDone && i === lastIdx ? `<b>→ ${s}</b>` : `<i>✓ ${s}</i>`)
117
- })
118
- }
119
-
120
- /**
121
- * Render the worker-activity message body as native Telegram HTML, matching
122
- * the main agent's activity card (`renderActivityFeed` in
123
- * tool-activity-summary.ts): a `🛠 Worker · <desc>` header, a one-line status,
124
- * then a `✓`/`→` step feed. Worker narration is authored as Markdown, so every
125
- * text fragment is run through `stripMarkdown` before escaping — without it the
126
- * raw `**`/`` ` ``/`---` leak through as literal characters (the "half-done"
127
- * look we're fixing).
93
+ * Thin adapter over the unified `renderStatusCard` primitive (emoji 🛠, label
94
+ * 'Worker'): builds the header, passes raw narrative steps (the primitive runs
95
+ * stripMarkdown collapse ws clip escape per line), and on finish passes
96
+ * the cleaned result paragraph as the `result` block.
128
97
  *
129
98
  * Layout (running):
130
99
  * 🛠 <b>Worker</b> · <i>{description}</i>
131
- * <i>running · {elapsed} · {n} tools</i>
100
+ * <i>{elapsed} · {n} tools</i>
132
101
  * <i>✓ {earlier step}</i>
133
102
  * <b>→ {newest step}</b>
134
103
  *
135
104
  * Layout (finished): the feed renders all-done, then a rule + cleaned result:
136
105
  * 🛠 <b>Worker</b> · <i>{description}</i>
137
- * <i>finished · completed · {n} tools · {elapsed}</i>
106
+ * <i>done · {n} tools · {elapsed}</i>
138
107
  * <i>✓ {step}</i>
139
108
  * ─────
140
109
  * ✅ <i>{cleaned result paragraph}</i>
141
110
  */
142
- export function renderWorkerActivity(v: WorkerActivityView): string {
111
+ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
143
112
  const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
144
- const elapsed = formatDuration(v.elapsedMs)
145
- const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
146
- const header = `🛠 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
147
113
  const finished = v.state === 'done' || v.state === 'failed'
148
114
 
149
- const steps = (v.narrativeLines ?? [])
150
- // A narrative entry can itself be multi-line (e.g. a worker's final
151
- // "Done.\n\n## Summary\n…"). Collapse to one visual line so a step
152
- // slot stays single-line after the per-line markdown strip.
153
- .map((s) => stripMarkdown(s).replace(/\s+/g, ' ').trim())
154
- .filter((s) => s.length > 0)
155
- .map((s) => escapeHtml(truncate(s, STEP_MAX)))
115
+ // Raw narrative steps (unstripped/unescaped) the unified renderer runs the
116
+ // full per-line pipeline (stripMarkdown collapse ws clip escape).
117
+ const rawSteps = (v.narrativeLines ?? []).filter((s) => s != null && s.trim().length > 0)
118
+
119
+ // Back-compat for direct render callers that pass only latestSummary while
120
+ // RUNNING; the manager always supplies narrativeLines. On the FINISHED path
121
+ // latestSummary is the worker's RESULT (below), never a narrative step — so
122
+ // the fallback only applies while running.
123
+ let steps = rawSteps
124
+ if (steps.length === 0 && !finished) {
125
+ const summary = stripMarkdown(v.latestSummary).replace(/\s+/g, ' ').trim()
126
+ if (summary.length > 0) steps = [v.latestSummary]
127
+ }
128
+
129
+ const header: Parameters<typeof renderStatusCard>[0]['header'] = {
130
+ emoji: '🛠',
131
+ label: 'Worker',
132
+ description: desc,
133
+ elapsedMs: v.elapsedMs,
134
+ toolCount: v.toolCount,
135
+ state: v.state,
136
+ }
156
137
 
138
+ // Terminal: latestSummary carries the worker's final result text (gateway
139
+ // onFinish), distinct from the running narrative steps. Pass it as `result`.
140
+ let result: { emoji: string; text: string } | undefined
157
141
  if (finished) {
158
- const verb = v.state === 'done' ? 'completed' : 'failed'
159
- const lines = [header, `<i>finished · ${verb} · ${v.toolCount} ${toolWord} · ${elapsed}</i>`]
160
- appendStepFeed(lines, steps, true)
161
- // On terminal, latestSummary carries the worker's final result text
162
- // (gateway onFinish), distinct from the running narrative steps.
163
- const result = cleanWorkerResultParagraph(v.latestSummary)
164
- if (result.length > 0) {
165
- const emoji = v.state === 'done' ? '✅' : '⚠️'
166
- lines.push(RULE)
167
- lines.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`)
168
- }
169
- return lines.join('\n')
142
+ const text = cleanWorkerResultParagraph(v.latestSummary)
143
+ if (text.length > 0) result = { emoji: v.state === 'done' ? '✅' : '⚠️', text }
170
144
  }
171
145
 
172
- const lines = [header, `<i>running · ${elapsed} · ${v.toolCount} ${toolWord}</i>`]
173
- if (steps.length > 0) {
174
- appendStepFeed(lines, steps, false)
175
- } else {
176
- // Back-compat for direct render callers that pass only latestSummary;
177
- // the manager always supplies narrativeLines.
178
- const summary = stripMarkdown(v.latestSummary).replace(/\s+/g, ' ').trim()
179
- if (summary.length > 0) {
180
- lines.push(`<b>→ ${escapeHtml(truncate(summary, STEP_MAX))}</b>`)
181
- } else {
182
- lines.push('<i>starting…</i>')
183
- }
146
+ // `renderStatusCard` always returns content when a header is supplied. When
147
+ // running with no steps it shows just the header — append a "starting…" line
148
+ // for parity with the prior behaviour.
149
+ const card = renderStatusCard({
150
+ header,
151
+ steps,
152
+ final: finished,
153
+ liveSuffix: finished ? '' : liveSuffix,
154
+ result,
155
+ })
156
+ if (card == null) {
157
+ // Unreachable (header always present) — defensive.
158
+ return `🛠 <b>Worker</b> · <i>starting…</i>`
184
159
  }
185
- return lines.join('\n')
160
+ if (!finished && steps.length === 0) {
161
+ // Header-only running render → append the starting placeholder.
162
+ return `${card}\n<i>starting…</i>`
163
+ }
164
+ return card
186
165
  }
187
166
 
188
167
  export interface WorkerActivityFeedOpts {
@@ -205,6 +184,19 @@ export interface WorkerActivityFeedOpts {
205
184
  firstPaintMinMs?: number
206
185
  /** stderr-style log sink. Defaults to noop. */
207
186
  log?: (msg: string) => void
187
+ /**
188
+ * Heartbeat timer factory. Injectable for tests. Defaults to the real
189
+ * `setInterval`, `.unref()`'d so it never keeps the process alive.
190
+ */
191
+ setInterval?: (cb: () => void, ms: number) => unknown
192
+ /** Heartbeat timer disposer. Injectable for tests. Defaults to `clearInterval`. */
193
+ clearInterval?: (handle: unknown) => void
194
+ /**
195
+ * Heartbeat tick cadence in ms. On each tick a stale, running worker is
196
+ * re-rendered with a climbing `· Ns` suffix so a worker that emits no new
197
+ * narrative still visibly advances. Default 6000ms.
198
+ */
199
+ heartbeatTickMs?: number
208
200
  }
209
201
 
210
202
  interface WorkerHandle {
@@ -218,12 +210,20 @@ interface WorkerHandle {
218
210
  cooldownUntil: number
219
211
  /**
220
212
  * Accumulated narrative lines (oldest→newest), deduped against the
221
- * immediately-preceding line and capped to NARRATIVE_MAX_LINES. Grows the
222
- * live render so the feed reads like the main agent's answer.
213
+ * immediately-preceding line. Rolling-window capped to STATUS_ROLLING_LINES.
214
+ * Grows the live render so the feed reads like the main agent's answer.
223
215
  */
224
216
  narrative: string[]
225
217
  /** Per-worker serialization chain so ticks can't interleave sends. */
226
218
  chain: Promise<void>
219
+ /** Last view rendered into the message (drives the heartbeat re-render). */
220
+ lastView: WorkerActivityView | null
221
+ /**
222
+ * Wall-clock ms the worker was dispatched, derived from `now - view.elapsedMs`
223
+ * on the first update. The heartbeat computes a live elapsed from this so the
224
+ * `· Ns` suffix climbs even when no fresh view arrives.
225
+ */
226
+ dispatchAtMs: number | null
227
227
  }
228
228
 
229
229
  const COOLDOWN_JITTER_MS = 500
@@ -257,6 +257,10 @@ export interface WorkerActivityFeed {
257
257
  finish(agentId: string, view: WorkerActivityView): Promise<void>
258
258
  /** Forget a worker's state without editing (e.g. error path). */
259
259
  drop(agentId: string): void
260
+ /** Clear the heartbeat interval (gateway shutdown). Idempotent. */
261
+ stop(): void
262
+ /** Manually fire one heartbeat tick (test hook). */
263
+ heartbeatTick(): void
260
264
  /** Number of tracked workers (test/inspection hook). */
261
265
  readonly size: number
262
266
  }
@@ -266,12 +270,28 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
266
270
  const nowFn = opts.now ?? Date.now
267
271
  const minEditInterval = opts.minEditIntervalMs ?? 2500
268
272
  const firstPaintMin = opts.firstPaintMinMs ?? 8000
273
+ const heartbeatTickMs = opts.heartbeatTickMs ?? 6000
274
+ const setIntervalFn =
275
+ opts.setInterval ??
276
+ ((cb: () => void, ms: number): unknown => {
277
+ const t = setInterval(cb, ms)
278
+ // Never keep the process alive on the heartbeat alone.
279
+ ;(t as { unref?: () => void }).unref?.()
280
+ return t
281
+ })
282
+ const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
269
283
  const handles = new Map<string, WorkerHandle>()
284
+ let heartbeatTimer: unknown = null
270
285
 
271
286
  function sendOptsFor(h: WorkerHandle): Record<string, unknown> {
272
287
  return {
273
288
  parse_mode: 'HTML',
274
289
  disable_web_page_preview: true,
290
+ // Sub-agent progress card is a status surface, never the user's
291
+ // answer — silence the open ping. (editMessageText ignores
292
+ // disable_notification, so this is a no-op on the in-place edits
293
+ // that share these opts.)
294
+ disable_notification: true,
275
295
  ...(h.threadId != null ? { message_thread_id: h.threadId } : {}),
276
296
  }
277
297
  }
@@ -290,17 +310,25 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
290
310
  // same narrative across ticks while a tool runs; we only grow on change.
291
311
  if (h.narrative[h.narrative.length - 1] === line) return
292
312
  h.narrative.push(line)
293
- if (h.narrative.length > NARRATIVE_MAX_LINES) {
294
- h.narrative.splice(0, h.narrative.length - NARRATIVE_MAX_LINES)
313
+ // Rolling window keep only the last STATUS_ROLLING_LINES in memory. The
314
+ // render shows exactly those lines (clipped per-line by the unified pipeline);
315
+ // fitCardToBudget is the wire-limit backstop.
316
+ if (h.narrative.length > STATUS_ROLLING_LINES) {
317
+ h.narrative.splice(0, h.narrative.length - STATUS_ROLLING_LINES)
295
318
  }
296
319
  }
297
320
 
298
- async function doUpdate(h: WorkerHandle, view: WorkerActivityView): Promise<void> {
321
+ async function doUpdate(h: WorkerHandle, view: WorkerActivityView, liveSuffix = ''): Promise<void> {
299
322
  // Accumulate before any gate so a throttled/cooled-down tick still grows
300
323
  // the narrative — the line surfaces on the next edit that does fire.
301
324
  accumulateNarrative(h, view)
325
+ // Stamp the dispatch wall-clock once so the heartbeat can climb a live
326
+ // elapsed even between fresh views. lastView feeds the heartbeat re-render.
327
+ const merged: WorkerActivityView = { ...view, narrativeLines: [...h.narrative] }
328
+ h.lastView = merged
329
+ if (h.dispatchAtMs == null) h.dispatchAtMs = nowFn() - view.elapsedMs
302
330
  if (nowFn() < h.cooldownUntil) return
303
- const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
331
+ const body = renderWorkerActivity(merged, liveSuffix)
304
332
 
305
333
  // First paint: hold off until the worker has run long enough to be
306
334
  // worth a message; trivial workers stay silent (handback covers them).
@@ -371,6 +399,44 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
371
399
  }
372
400
  }
373
401
 
402
+ /**
403
+ * Heartbeat — option (a), suffix-only, NEVER opens a new message. For each
404
+ * handle with a posted message, enqueue a re-render through the existing
405
+ * chain → doUpdate path (never editMessageText directly). Skips:
406
+ * - handles with no posted message (messageId == null),
407
+ * - handles inside a 429 cooldown,
408
+ * - handles edited within minEditInterval (no stampede),
409
+ * - non-running handles (terminal/deleted).
410
+ * The `· Ns` liveSuffix is applied ONLY when the worker's current step is
411
+ * stale (now - lastEditAt >= heartbeatTickMs) so a normally-ticking worker is
412
+ * untouched and its body stays byte-stable for the dedup.
413
+ */
414
+ function heartbeatTick(): void {
415
+ const now = nowFn()
416
+ for (const h of handles.values()) {
417
+ if (h.messageId == null) continue
418
+ if (h.lastView == null) continue
419
+ if (h.lastView.state !== 'running') continue
420
+ if (now < h.cooldownUntil) continue
421
+ if (now - h.lastEditAt < minEditInterval) continue
422
+ const stale = now - h.lastEditAt >= heartbeatTickMs
423
+ if (!stale) continue
424
+ const liveElapsed = h.dispatchAtMs != null ? now - h.dispatchAtMs : h.lastView.elapsedMs
425
+ const liveSuffix = ' · ' + formatFeedElapsed(liveElapsed)
426
+ // Re-render THROUGH the chain + doUpdate path — never editMessageText directly.
427
+ const view = h.lastView
428
+ h.chain = h.chain
429
+ .then(() => doUpdate(h, view, liveSuffix))
430
+ .catch((err) => {
431
+ log(`worker-feed: heartbeat chain error ${h.agentId}: ${(err as Error).message}`)
432
+ })
433
+ }
434
+ }
435
+
436
+ // Arm the heartbeat once at construction. The real timer is `.unref()`'d so
437
+ // it never keeps the process alive; tests inject setInterval/clearInterval.
438
+ heartbeatTimer = setIntervalFn(heartbeatTick, heartbeatTickMs)
439
+
374
440
  return {
375
441
  has(agentId) {
376
442
  return handles.get(agentId)?.messageId != null
@@ -394,6 +460,8 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
394
460
  cooldownUntil: 0,
395
461
  narrative: [],
396
462
  chain: Promise.resolve(),
463
+ lastView: null,
464
+ dispatchAtMs: null,
397
465
  }
398
466
  handles.set(agentId, h)
399
467
  }
@@ -419,5 +487,12 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
419
487
  drop(agentId) {
420
488
  handles.delete(agentId)
421
489
  },
490
+ heartbeatTick,
491
+ stop() {
492
+ if (heartbeatTimer != null) {
493
+ clearIntervalFn(heartbeatTimer)
494
+ heartbeatTimer = null
495
+ }
496
+ },
422
497
  }
423
498
  }
@@ -1,122 +0,0 @@
1
- /**
2
- * Shared draft-transport helpers for answer-stream and draft-stream.
3
- *
4
- * Extracted from answer-stream.ts so both the narrative answer-lane and the
5
- * model-driven stream_reply lane can share the same regex constants and
6
- * fallback logic without duplicating them.
7
- *
8
- * answer-stream.ts re-exports these symbols so existing callers (including
9
- * tests that import directly from answer-stream.ts) continue to work.
10
- */
11
-
12
- // Error patterns matching OpenClaw's shouldFallbackFromDraftTransport.
13
- // Exported for tests.
14
- export const DRAFT_METHOD_UNAVAILABLE_RE =
15
- /(unknown method|method .*not (found|available|supported)|unsupported)/i
16
- export const DRAFT_CHAT_UNSUPPORTED_RE = /(can't be used|can be used only)/i
17
-
18
- /**
19
- * Returns true when a sendMessageDraft rejection means "this API is not
20
- * available" rather than a transient network error.
21
- */
22
- export function shouldFallbackFromDraftTransport(err: unknown): boolean {
23
- const text =
24
- typeof err === 'string'
25
- ? err
26
- : err instanceof Error
27
- ? err.message
28
- : typeof err === 'object' && err != null && 'description' in err
29
- ? typeof (err as { description: unknown }).description === 'string'
30
- ? (err as { description: string }).description
31
- : ''
32
- : ''
33
- if (!/sendMessageDraft/i.test(text)) return false
34
- return DRAFT_METHOD_UNAVAILABLE_RE.test(text) || DRAFT_CHAT_UNSUPPORTED_RE.test(text)
35
- }
36
-
37
- /**
38
- * PR D — extract the `retry_after` seconds from a grammY 429 error.
39
- * Returns null when the error isn't a 429 (or has no retry_after).
40
- *
41
- * Shared with `issues-card.ts:extractRetryAfterSecs`. Duck-typed on the
42
- * documented grammY `GrammyError` shape to keep this module
43
- * test-friendly without importing `GrammyError` directly.
44
- */
45
- export function extractDraft429RetryAfterSecs(err: unknown): number | null {
46
- if (err == null || typeof err !== 'object') return null
47
- const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
48
- if (e.error_code !== 429) return null
49
- const ra = e.parameters?.retry_after
50
- if (typeof ra === 'number' && Number.isFinite(ra) && ra > 0) return ra
51
- return null
52
- }
53
-
54
- /**
55
- * PR D — was this a 429 from `sendMessageDraft` specifically? Used by
56
- * draft-stream to differentiate "draft is rate-limited" (transient,
57
- * just back off this stream) from a non-429 send error (handled
58
- * separately by `shouldFallbackFromDraftTransport`).
59
- *
60
- * Both cases trigger fallback to message transport for the rest of
61
- * the stream, but the 429 case ALSO bumps the throttle window to
62
- * honor Telegram's `retry_after` — so the message-transport fallback
63
- * doesn't immediately fire a fresh send before Telegram's cooldown
64
- * elapses and re-429s.
65
- */
66
- export function isDraft429(err: unknown): boolean {
67
- if (extractDraft429RetryAfterSecs(err) == null) return false
68
- // grammY GrammyError carries the method name in its `method` field.
69
- // Best-effort: match either the structured method or the error text.
70
- if (typeof err === 'object' && err != null && 'method' in err) {
71
- const m = (err as { method?: unknown }).method
72
- if (typeof m === 'string' && /sendMessageDraft/i.test(m)) return true
73
- }
74
- const text =
75
- typeof err === 'string'
76
- ? err
77
- : err instanceof Error
78
- ? err.message
79
- : typeof err === 'object' && err != null && 'description' in err
80
- ? typeof (err as { description: unknown }).description === 'string'
81
- ? (err as { description: string }).description
82
- : ''
83
- : ''
84
- return /sendMessageDraft/i.test(text)
85
- }
86
-
87
- /**
88
- * Symbol-keyed shared counter for draft-id allocation across concurrent
89
- * streams (mirrors openclaw's getDraftStreamState). Using Symbol.for ensures
90
- * the same counter is shared even if this module is loaded multiple times
91
- * (e.g. from different bundle chunks).
92
- */
93
- const DRAFT_STREAM_STATE_KEY = Symbol.for('switchroom.draftStreamState')
94
-
95
- interface DraftStreamState {
96
- nextDraftId: number
97
- }
98
-
99
- function getDraftStreamState(): DraftStreamState {
100
- const g = globalThis as Record<PropertyKey, unknown>
101
- let state = g[DRAFT_STREAM_STATE_KEY] as DraftStreamState | undefined
102
- if (!state) {
103
- state = { nextDraftId: 0 }
104
- g[DRAFT_STREAM_STATE_KEY] = state
105
- }
106
- return state
107
- }
108
-
109
- /**
110
- * Allocate a unique draft ID, wrapping at 2_147_483_647 (Telegram's int32
111
- * max for draft_id). IDs start at 1 and cycle.
112
- */
113
- export function allocateDraftId(): number {
114
- const state = getDraftStreamState()
115
- state.nextDraftId = state.nextDraftId >= 2_147_483_647 ? 1 : state.nextDraftId + 1
116
- return state.nextDraftId
117
- }
118
-
119
- /** Reset the shared draft-id counter — for tests only. */
120
- export function __resetDraftIdForTests(): void {
121
- getDraftStreamState().nextDraftId = 0
122
- }
@@ -1,82 +0,0 @@
1
- /**
2
- * Answer-lane wiring guards — draft retirement + flash decoupling.
3
- *
4
- * History:
5
- * - v0.14.52 turned the VISIBLE answer-stream OFF by default to remove the
6
- * "raw preview appears, formatted reply lands, raw preview deleted" flash.
7
- * - v0.14.68 retired the invisible compose-box DRAFT transport. The original
8
- * wiring (and the first version of this test) conflated the two flags —
9
- * `ANSWER_STREAM_VISIBLE_ENABLED || DRAFT_ANSWER_LANE_RETIRED` — so retiring
10
- * the draft (default) silently forced a VISIBLE preview (minInitialChars:1)
11
- * even with the visible flag off, re-introducing the flash FLEET-WIDE
12
- * (v0.14.68 undid v0.14.52). The first revision of this file pinned that
13
- * conflation as a "guard" — i.e. it asserted the bug.
14
- * - 2026-06-05 flash-decouple: the VISIBLE preview now gates on the visible
15
- * flag ALONE; DRAFT_ANSWER_LANE_RETIRED controls only the TRANSPORT. With
16
- * defaults (visible off, draft retired) the lane is DORMANT — no preview, the
17
- * reply tool is the single formatted message, no flash. The no-reply text-only
18
- * answer is delivered by the turn-flush backstop (the pre-v0.14.68 path).
19
- *
20
- * gateway IIFE can't be instantiated in-process, so these are source-level
21
- * assertions (same pattern as silence-liveness-wiring.test).
22
- */
23
- import { describe, it, expect } from 'vitest'
24
- import { readFileSync } from 'node:fs'
25
- import { resolve } from 'node:path'
26
-
27
- const gatewaySrc = readFileSync(resolve(__dirname, '..', 'gateway', 'gateway.ts'), 'utf-8')
28
-
29
- describe('answer-lane wiring (draft retirement + flash decoupling)', () => {
30
- it('sendMessageDraftFn is gated on the retirement (the single transport chokepoint)', () => {
31
- expect(gatewaySrc).toMatch(/!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft === 'function'/)
32
- })
33
-
34
- it('DRAFT_ANSWER_LANE_RETIRED is declared before its first use (no TDZ at boot)', () => {
35
- const declIdx = gatewaySrc.indexOf('const DRAFT_ANSWER_LANE_RETIRED =')
36
- const firstUseIdx = gatewaySrc.indexOf('!DRAFT_ANSWER_LANE_RETIRED && typeof _rawSendMessageDraft')
37
- expect(declIdx).toBeGreaterThan(0)
38
- expect(firstUseIdx).toBeGreaterThan(declIdx)
39
- })
40
-
41
- it('the lane behaviour is resolved by the single-source-of-truth pure function', () => {
42
- // The flag→config decision lives in resolveAnswerLaneConfig (total-enumerated
43
- // in answer-stream-flag.test.ts); the gateway delegates so the three use-sites
44
- // can never drift apart.
45
- expect(gatewaySrc).toMatch(/const ANSWER_LANE = resolveAnswerLaneConfig\(\{/)
46
- expect(gatewaySrc).toMatch(/visibleEnabled: ANSWER_STREAM_VISIBLE_ENABLED/)
47
- expect(gatewaySrc).toMatch(/draftFnAvailable: sendMessageDraftFn != null/)
48
- })
49
-
50
- it('FLASH GUARD: the createAnswerStream config is driven by ANSWER_LANE (preview gated on the visible flag alone)', () => {
51
- // The minInitialChars / draft-transport choice comes from the resolved lane,
52
- // never from a `visible || retired` inline conflation.
53
- expect(gatewaySrc).toMatch(/\.\.\.\(ANSWER_LANE\.usesDraftTransport/)
54
- expect(gatewaySrc).toMatch(/minInitialChars: ANSWER_LANE\.minInitialChars/)
55
- })
56
-
57
- it('FLASH GUARD: the visible-OR-retired conflation is GONE everywhere (it pinned the flash)', () => {
58
- // No answer-lane path may re-introduce `VISIBLE || RETIRED` — that is the
59
- // exact regression this fix removes.
60
- expect(gatewaySrc).not.toMatch(/ANSWER_STREAM_VISIBLE_ENABLED \|\| DRAFT_ANSWER_LANE_RETIRED/)
61
- })
62
-
63
- it('LOST-ANSWER GUARD: materialize-as-answer gates on ANSWER_LANE.opensVisiblePreview (only fires when a preview actually opened)', () => {
64
- // A text-only no-reply turn materializes (ping + keep) ONLY when a visible
65
- // preview opened. With visible off the lane is dormant (minInitialChars:MAX),
66
- // so this branch is unreachable and the answer is delivered by turn-flush
67
- // instead — never retracted away.
68
- expect(gatewaySrc).toMatch(/\n\s*ANSWER_LANE\.opensVisiblePreview\s*\n\s*&& !turn\.replyCalled/)
69
- })
70
-
71
- it('LOST-ANSWER GUARD: turn-flush is skipped ONLY when the stream finalized as the answer (so dormant-lane no-reply turns still flush)', () => {
72
- // flushDecision skips only on streamFinalizedAsAnswer; with the lane dormant
73
- // that stays false, so decideTurnFlush runs and delivers the no-reply answer.
74
- expect(gatewaySrc).toMatch(/const flushDecision = streamFinalizedAsAnswer\s*\n?\s*\?/)
75
- })
76
-
77
- it('the #2169 onMetric silence-liveness reset is still wired (fires on visible sends when opted in)', () => {
78
- const onMetric = (gatewaySrc.split('onMetric: (metricEv) => {')[1] ?? '').split('\n },')[0]
79
- expect(onMetric).toMatch(/silencePoke\.noteProduction/)
80
- expect(onMetric).toMatch(/currentTurn === turn/)
81
- })
82
- })