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
@@ -6,8 +6,8 @@
6
6
  * Each non-surface tool gets a human-friendly, present-tense line
7
7
  * ("Reading CLAUDE.md", "Searching memory", "Running a command"); the
8
8
  * feed renders them chronologically (oldest first, newest = the
9
- * in-progress step), consecutive duplicates collapsed, capped to the
10
- * most recent MIRROR_MAX_LINES with a "+N earlier" header.
9
+ * in-progress step), consecutive duplicates collapsed, windowed to the
10
+ * most recent STATUS_ROLLING_LINES with a "+N earlier" overflow header.
11
11
  *
12
12
  * Two append entrypoints feed the same `lines: string[]` accumulator:
13
13
  * - `appendActivityLabel` — for a pre-computed label from the
@@ -81,7 +81,8 @@ export function describeToolUse(
81
81
  const server = mcpMatch[1].toLowerCase();
82
82
  const tool = mcpMatch[2].toLowerCase();
83
83
  // Surface tools ARE the conversation — never mirror them.
84
- if (server === "switchroom-telegram") return null;
84
+ // Use isTelegramSurfaceTool (regex-based, key-agnostic) so forks/renames work.
85
+ if (isTelegramSurfaceTool(toolName)) return null;
85
86
  if (server === "hindsight") {
86
87
  if (tool === "recall" || tool === "reflect") return "Searching memory";
87
88
  if (tool === "retain" || tool === "update_memory" || tool === "sync_retain")
@@ -162,10 +163,38 @@ export function describeToolUse(
162
163
  // Accumulates the turn's actions into a running feed — like Claude Code's
163
164
  // own UI — rendered into one Telegram message that edits in place and is
164
165
  // cleared on reply. Chronological (oldest first, newest last), consecutive
165
- // exact-duplicates collapsed, capped to the most recent MIRROR_MAX_LINES
166
- // with a "+N earlier" header so a heavy turn stays readable.
166
+ // exact-duplicates collapsed.
167
+ //
168
+ // Both this surface (agent) and the worker feed render through the single
169
+ // `renderStatusCard` primitive below: a rolling window of the last
170
+ // STATUS_ROLLING_LINES steps, each capped at STATUS_LINE_MAX chars, with a
171
+ // `+N earlier…` header when the feed overflows, bounded by the 4000-char
172
+ // wire-limit backstop (STATUS_CARD_CHAR_BUDGET).
167
173
 
168
- export const MIRROR_MAX_LINES = 6;
174
+ import {
175
+ STATUS_CARD_CHAR_BUDGET,
176
+ STATUS_ROLLING_LINES,
177
+ STATUS_LINE_MAX,
178
+ NESTED_PREFIX,
179
+ } from './status-no-truncate.js'
180
+ import { escapeHtml, stripMarkdown, truncate } from './card-format.js'
181
+ import { isTelegramSurfaceTool } from './tool-names.js'
182
+
183
+ /**
184
+ * Optional header for the main-session activity card, matching the worker
185
+ * card's two-line header style so both cards render consistently.
186
+ *
187
+ * `label` — first header line text (e.g. "Agent")
188
+ * `elapsedMs`— wall-clock since the turn started (for the elapsed field)
189
+ * `toolCount`— labeled (non-surface) tool calls so far
190
+ * `state` — 'running' | 'done' (controls finished vs. in-progress status wording)
191
+ */
192
+ export interface SessionActivityHeader {
193
+ label: string
194
+ elapsedMs: number
195
+ toolCount: number
196
+ state: 'running' | 'done'
197
+ }
169
198
 
170
199
  /**
171
200
  * Append a tool_use's friendly line to the running feed (mutates `lines`)
@@ -188,42 +217,338 @@ export function appendActivityLine(
188
217
  return renderActivityFeed(lines);
189
218
  }
190
219
 
191
- /** Minimal HTML escape for Telegram parse_mode=HTML (matches the gateway's). */
192
- function escapeFeedHtml(s: string): string {
193
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
220
+ /**
221
+ * Clip a raw narrative text block down to a single durable feed line:
222
+ * first line only, trimmed, sliced to STATUS_LINE_MAX (200) chars.
223
+ *
224
+ * 200 chars matches the tool-label cap used by `escapeStepLine` / `renderStepFeed`
225
+ * so a narrative line is legible at the same length as a tool step — "Analysing
226
+ * the 12 changed files in /src/auth to understand the scope of…" rather than a
227
+ * hard-truncated 120-char fragment that drops context mid-sentence.
228
+ *
229
+ * Shared by the main-agent gateway path and the sub-agent watcher so both render
230
+ * narrative identically. Returns the raw (unescaped) clipped string — callers
231
+ * escape via the renderStepFeed path, exactly like a tool label.
232
+ */
233
+ export function clipNarrative(s: string): string {
234
+ // `s` is a non-nullable string at every call site (showNarrativeStep passes
235
+ // ev.text; the foreground-sub path passes `progressLine ?? latestSummary`,
236
+ // both typed string), so no null guard is needed.
237
+ return s.split('\n')[0].trim().slice(0, STATUS_LINE_MAX);
238
+ }
239
+
240
+ /**
241
+ * Render a two-line header for the activity card, matching the worker card's
242
+ * style. Used by both the main-session card and the worker card.
243
+ *
244
+ * Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
245
+ * Line 2: status + elapsed + tool count
246
+ *
247
+ * `emoji` — leading emoji (e.g. "🤖", "🛠")
248
+ * `label` — bold name (e.g. "Agent", "Worker")
249
+ * `description` — italicised task description (optional)
250
+ * `elapsedMs` — wall-clock elapsed, rendered via `formatFeedElapsed`
251
+ * `toolCount` — labeled tool calls this turn
252
+ * `state` — 'running' | 'done' | 'failed' (controls the status line wording;
253
+ * 'failed' renders `failed · …` so a failed worker never reads as done)
254
+ *
255
+ * Returns a two-element array of ready Telegram HTML lines (no trailing newline).
256
+ */
257
+ export function renderActivityHeader(
258
+ emoji: string,
259
+ label: string,
260
+ description: string,
261
+ elapsedMs: number,
262
+ toolCount: number,
263
+ state: 'running' | 'done' | 'failed',
264
+ ): [string, string] {
265
+ const toolWord = toolCount === 1 ? 'tool' : 'tools'
266
+ const elapsed = formatFeedElapsed(elapsedMs)
267
+ const descPart = description.length > 0 ? ` · <i>${escapeHtml(description)}</i>` : ''
268
+ const line1 = `${emoji} <b>${escapeHtml(label)}</b>${descPart}`
269
+ const line2 = state === 'running'
270
+ ? `<i>${elapsed} · ${toolCount} ${toolWord}</i>`
271
+ : `<i>${state} · ${toolCount} ${toolWord} · ${elapsed}</i>`
272
+ return [line1, line2]
273
+ }
274
+
275
+ /** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
276
+ export function formatFeedElapsed(ms: number): string {
277
+ const s = Math.floor(ms / 1000)
278
+ if (s < 60) return `${s}s`
279
+ const m = Math.floor(s / 60)
280
+ return `${m}m${(s % 60).toString().padStart(2, '0')}s`
281
+ }
282
+
283
+ // ─── Truncation pipeline (the single correctness-critical primitive) ────────
284
+ //
285
+ // Per RAW line, in this EXACT order:
286
+ // 1. stripMarkdown(raw)
287
+ // 2. .replace(/\s+/g, ' ').trim()
288
+ // 3. truncate(_, STATUS_LINE_MAX)
289
+ // 4. escapeHtml(_) ← escape is ALWAYS the last per-line op.
290
+ // Escaping last is load-bearing: clipping an already-escaped string can split
291
+ // an HTML entity (&amp; → &amp), which Telegram's HTML parser rejects.
292
+
293
+ /** Clean + clip + escape a single raw step line. Returns ready-to-wrap HTML. */
294
+ function escapeStepLine(raw: string): string {
295
+ const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
296
+ return escapeHtml(truncate(cleaned, STATUS_LINE_MAX))
297
+ }
298
+
299
+ /**
300
+ * Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
301
+ * given ALREADY-ESCAPED step strings, windowing to STATUS_ROLLING_LINES and
302
+ * prepending a `+N earlier…` header when the feed overflows the window (on
303
+ * BOTH surfaces now). The worker feed imports this directly.
304
+ *
305
+ * `out` — accumulator mutated in place
306
+ * `steps` — pre-cleaned + pre-escaped HTML step strings
307
+ * `allDone` — when true ALL steps render done (✓ italic); when false the
308
+ * newest renders in-progress (→ bold)
309
+ * `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
310
+ */
311
+ export function renderStepFeed(
312
+ out: string[],
313
+ steps: string[],
314
+ allDone: boolean,
315
+ liveSuffix = '',
316
+ ): void {
317
+ if (steps.length === 0) return
318
+ const shown = steps.slice(-STATUS_ROLLING_LINES)
319
+ const hidden = steps.length - shown.length
320
+ if (hidden > 0) out.push(`<i>✓ +${hidden} earlier…</i>`)
321
+ const lastIdx = shown.length - 1
322
+ shown.forEach((s, i) => {
323
+ out.push(!allDone && i === lastIdx ? `<b>→ ${s}${liveSuffix}</b>` : `<i>✓ ${s}</i>`)
324
+ })
325
+ }
326
+
327
+ // ─── Unified status-card primitive ──────────────────────────────────────────
328
+ //
329
+ // Both status surfaces (🤖 agent + 🛠 worker) render through `renderStatusCard`.
330
+ // It is the single place that runs the per-line truncation pipeline, windows
331
+ // the rolling feed, prepends `+N earlier…`, wraps bullets, indents child
332
+ // steps, and applies the total-budget backstop.
333
+
334
+ /** Header block for a status card. Emoji 🤖 (agent) / 🛠 (worker). */
335
+ export interface StatusCardHeader {
336
+ emoji: string
337
+ label: string
338
+ description?: string
339
+ elapsedMs: number
340
+ toolCount: number
341
+ state: 'running' | 'done' | 'failed'
342
+ }
343
+
344
+ /** Inputs to the unified status-card renderer. */
345
+ export interface StatusCardOpts {
346
+ /** Optional two-line header. When omitted, the card is steps-only. */
347
+ header?: StatusCardHeader
348
+ /** RAW parent step strings (unstripped, unescaped). */
349
+ steps: string[]
350
+ /** RAW nested child step strings (foreground sub-agent), unstripped/unescaped. */
351
+ childSteps?: string[]
352
+ /** Terminal record render — newest line renders done (✓) and `liveSuffix` is ignored. */
353
+ final?: boolean
354
+ /** Heartbeat suffix appended INSIDE the newest in-progress line (live only). */
355
+ liveSuffix?: string
356
+ /** When `final` and > 0, appends a `✓ N steps` footer. */
357
+ stepCount?: number
358
+ /** Optional terminal result block (worker recap), already-cleaned text + emoji. */
359
+ result?: { emoji: string; text: string }
360
+ }
361
+
362
+ /**
363
+ * Render the unified status card as ready Telegram HTML, or null when there is
364
+ * no content to show (no header content, no steps, no children, no result).
365
+ *
366
+ * Pipeline: header → escape+clip every raw step/child → rolling-window each
367
+ * group with `+N earlier…` → wrap (parent done-styled when children present;
368
+ * newest-in-progress `→` bold else `✓` italic; NESTED_PREFIX for children) →
369
+ * optional `✓ N steps` footer → optional result block → fitCardToBudget.
370
+ */
371
+ export function renderStatusCard(opts: StatusCardOpts): string | null {
372
+ const { header, final = false, liveSuffix = '', stepCount, result } = opts
373
+ const rawSteps = opts.steps.filter((s) => s != null)
374
+ const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
375
+ const hasChildren = rawChildren.length > 0
376
+
377
+ // Escape every line through the per-line pipeline (escape last).
378
+ const steps = rawSteps.map(escapeStepLine)
379
+ const children = rawChildren.map(escapeStepLine)
380
+
381
+ const headerLines = header != null
382
+ ? renderActivityHeader(
383
+ header.emoji,
384
+ header.label,
385
+ header.description ?? '',
386
+ header.elapsedMs,
387
+ header.toolCount,
388
+ // Thread the terminal state straight through so a failed worker reads
389
+ // `failed · …` on line 2 — never byte-identical to a done worker even
390
+ // when the result block is empty. The agent surface never passes
391
+ // 'failed', so only the worker card is affected.
392
+ header.state,
393
+ )
394
+ : []
395
+
396
+ const out: string[] = [...headerLines]
397
+
398
+ if (hasChildren) {
399
+ // Parent lines all render done — the live → step lives in the nested block.
400
+ const shownParent = steps.slice(-STATUS_ROLLING_LINES)
401
+ const hiddenParent = steps.length - shownParent.length
402
+ if (hiddenParent > 0) out.push(`<i>✓ +${hiddenParent} earlier…</i>`)
403
+ for (const s of shownParent) out.push(`<i>✓ ${s}</i>`)
404
+ // Child block.
405
+ const shownChild = children.slice(-STATUS_ROLLING_LINES)
406
+ const hiddenChild = children.length - shownChild.length
407
+ if (hiddenChild > 0) out.push(`${NESTED_PREFIX}<i>+${hiddenChild} earlier…</i>`)
408
+ const lastChildIdx = shownChild.length - 1
409
+ shownChild.forEach((s, i) => {
410
+ out.push(
411
+ i === lastChildIdx && !final
412
+ ? `${NESTED_PREFIX}<b>→ ${s}${liveSuffix}</b>`
413
+ : `${NESTED_PREFIX}<i>${s}</i>`,
414
+ )
415
+ })
416
+ } else {
417
+ renderStepFeed(out, steps, final, liveSuffix)
418
+ }
419
+
420
+ if (final && stepCount != null && stepCount > 0) {
421
+ out.push(`<i>✓ ${stepCount} steps</i>`)
422
+ }
423
+
424
+ if (result != null && result.text.length > 0) {
425
+ out.push(WORKER_RESULT_RULE)
426
+ out.push(`${result.emoji} <i>${escapeHtml(truncate(result.text, WORKER_RESULT_MAX))}</i>`)
427
+ }
428
+
429
+ // out always carries the two header lines, so it is never empty — but guard
430
+ // against a degenerate header-less future caller.
431
+ if (out.length === 0) return null
432
+ const joined = out.join('\n')
433
+ if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
434
+ return fitCardToBudget(opts, headerLines)
435
+ }
436
+
437
+ /** Subtle horizontal rule between the running feed and the finished result. */
438
+ const WORKER_RESULT_RULE = '─────'
439
+ /** Hard cap on the terminal result paragraph. */
440
+ const WORKER_RESULT_MAX = 320
441
+
442
+ /**
443
+ * Char-budget backstop. Keeps the header / footer / result block fixed and
444
+ * drops the oldest body bullets one at a time, re-inserting a `+N earlier…`
445
+ * marker, until the card fits STATUS_CARD_CHAR_BUDGET. In the extreme case
446
+ * (a single newest bullet that is itself oversized) it truncates the RAW
447
+ * newest text, THEN escapes, THEN wraps — never slicing already-escaped HTML.
448
+ */
449
+ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
450
+ const { final = false, liveSuffix = '', stepCount, result } = opts
451
+ const rawSteps = opts.steps.filter((s) => s != null)
452
+ const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
453
+ const hasChildren = rawChildren.length > 0
454
+
455
+ // Fixed footer/result lines (always kept).
456
+ const footerLines: string[] = []
457
+ if (final && stepCount != null && stepCount > 0) footerLines.push(`<i>✓ ${stepCount} steps</i>`)
458
+ if (result != null && result.text.length > 0) {
459
+ footerLines.push(WORKER_RESULT_RULE)
460
+ footerLines.push(`${result.emoji} <i>${escapeHtml(truncate(result.text, WORKER_RESULT_MAX))}</i>`)
461
+ }
462
+ const fixedCost = [...headerLines, ...footerLines].join('\n').length
463
+
464
+ // The active "body" group whose oldest bullets we drop: children when
465
+ // present (parent collapses to a single "+N" marker), else parent steps.
466
+ const body = hasChildren ? rawChildren : rawSteps
467
+ const escapedBody = body.map(escapeStepLine)
468
+ const prefix = hasChildren ? NESTED_PREFIX : ''
469
+
470
+ const buildBullet = (esc: string, isLast: boolean): string =>
471
+ !final && isLast ? `${prefix}<b>→ ${esc}${liveSuffix}</b>` : `${prefix}<i>${esc}</i>`
472
+
473
+ // Parent-collapsed marker line when we are dropping children but parent steps exist.
474
+ const parentMarker =
475
+ hasChildren && rawSteps.length > 0 ? `<i>✓ +${rawSteps.length} earlier…</i>` : null
476
+
477
+ for (let drop = 1; drop < escapedBody.length; drop++) {
478
+ const shown = escapedBody.slice(drop)
479
+ const lines: string[] = [...headerLines]
480
+ if (parentMarker != null) lines.push(parentMarker)
481
+ lines.push(hasChildren ? `${NESTED_PREFIX}<i>+${drop} earlier…</i>` : `<i>✓ +${drop} earlier…</i>`)
482
+ const lastIdx = shown.length - 1
483
+ shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
484
+ lines.push(...footerLines)
485
+ const candidate = lines.join('\n')
486
+ if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
487
+ }
488
+
489
+ // Extreme: the single newest bullet is itself oversized. Truncate the RAW
490
+ // newest text, then escape, then wrap — re-checking post-escape because
491
+ // escaping can expand the string (& → &amp;).
492
+ const rawNewest = body.length > 0 ? stripMarkdown(body[body.length - 1]).replace(/\s+/g, ' ').trim() : ''
493
+ const wrapperOverhead = final
494
+ ? (prefix + '<i>✓ </i>').length
495
+ : (prefix + '<b>→ </b>').length + liveSuffix.length
496
+ const headerFooterCost =
497
+ fixedCost + (fixedCost > 0 ? 1 : 0) + (parentMarker != null ? parentMarker.length + 1 : 0)
498
+ const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
499
+ let raw = rawNewest.slice(0, Math.max(0, budget))
500
+ let newest = escapeHtml(raw)
501
+ while (raw.length > 0 && wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET) {
502
+ const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
503
+ raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
504
+ newest = escapeHtml(raw)
505
+ }
506
+ const newestLine = final ? `${prefix}<i>✓ ${newest}</i>` : `${prefix}<b>→ ${newest}${liveSuffix}</b>`
507
+ const lines: string[] = [...headerLines]
508
+ if (parentMarker != null) lines.push(parentMarker)
509
+ lines.push(newestLine)
510
+ lines.push(...footerLines)
511
+ return lines.join('\n')
194
512
  }
195
513
 
196
514
  /**
197
515
  * Render the accumulated feed as ready Telegram HTML — one action per line,
198
516
  * newest last. The current (newest) step is bold with a `→`; finished steps
199
- * are italic with a `✓`. Capped to the last MIRROR_MAX_LINES with a dim
517
+ * are italic with a `✓`. Capped to the last STATUS_ROLLING_LINES with a dim
200
518
  * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
201
519
  * Callers send the result verbatim — do NOT re-escape or re-wrap it.
520
+ *
521
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent').
522
+ *
523
+ * `stepCount` (optional): when `final=true` and `stepCount > 0`, appends a
524
+ * `✓ N steps` footer line.
525
+ *
526
+ * `header` (optional): when provided, the two-line activity header carries
527
+ * elapsed + tool count.
202
528
  */
203
529
  export function renderActivityFeed(
204
530
  lines: string[],
205
531
  final = false,
206
532
  liveSuffix = "",
533
+ stepCount?: number,
534
+ header?: SessionActivityHeader,
207
535
  ): string | null {
208
- if (lines.length === 0) return null;
209
- const shown = lines.slice(-MIRROR_MAX_LINES);
210
- const hidden = lines.length - shown.length;
211
- const out: string[] = [];
212
- if (hidden > 0) out.push(`<i>✓ +${hidden} earlier…</i>`);
213
- const lastIdx = shown.length - 1;
214
- // Newest line = in-progress step (bold, →); earlier = done (italic, ✓).
215
- // `final` (turn complete, feed left as a record): ALL lines render done (✓)
216
- // so the persisted message doesn't freeze on a misleading "→ in-progress".
217
- // `liveSuffix` (heartbeat): appended INSIDE the newest in-progress line only
218
- // (e.g. " · 18s") so the feed visibly advances during a long single step that
219
- // emits no new tool label — the feed is otherwise pull-only and freezes.
220
- // Caller passes framework-generated, HTML-safe text; never final + suffix.
221
- // Returns ready Telegram HTML — callers must NOT re-escape or re-wrap it.
222
- shown.forEach((l, i) => {
223
- const esc = escapeFeedHtml(l);
224
- out.push(i === lastIdx && !final ? `<b>→ ${esc}${liveSuffix}</b>` : `<i>✓ ${esc}</i>`);
225
- });
226
- return out.join("\n");
536
+ if (lines.length === 0 && header == null) return null;
537
+ return renderStatusCard({
538
+ header: header != null
539
+ ? {
540
+ emoji: '🤖',
541
+ label: header.label,
542
+ elapsedMs: header.elapsedMs,
543
+ toolCount: header.toolCount,
544
+ state: header.state,
545
+ }
546
+ : undefined,
547
+ steps: lines,
548
+ final,
549
+ liveSuffix,
550
+ stepCount,
551
+ })
227
552
  }
228
553
 
229
554
  // ─── Foreground sub-agent nesting (Model A) ─────────────────────────────────
@@ -238,10 +563,6 @@ export function renderActivityFeed(
238
563
 
239
564
  /** Trailing nested child lines kept visible (Telegram length + readability). */
240
565
  export const NESTED_MAX_LINES = 4;
241
- /** Hard cap on a single nested narrative line. */
242
- const NESTED_LINE_MAX = 90;
243
- /** Indent marker for a nested sub-agent step. */
244
- const NESTED_PREFIX = " ↳ ";
245
566
 
246
567
  /**
247
568
  * Render the parent activity feed with an active foreground sub-agent's steps
@@ -251,39 +572,35 @@ const NESTED_PREFIX = " ↳ ";
251
572
  * and the child block is indented, newest = bold `→`, earlier = italic, with
252
573
  * a `↳ +N earlier…` header when it overflows. Returns ready Telegram HTML
253
574
  * (callers must NOT re-escape) or null when there is nothing to show.
575
+ *
576
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent', childSteps).
254
577
  */
255
578
  export function renderActivityFeedWithNested(
256
579
  lines: string[],
257
580
  childLines: string[],
258
581
  final = false,
259
582
  liveSuffix = "",
583
+ stepCount?: number,
584
+ header?: SessionActivityHeader,
260
585
  ): string | null {
261
586
  const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
262
- if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix);
263
-
264
- const out: string[] = [];
265
- const shownParent = lines.slice(-MIRROR_MAX_LINES);
266
- const hiddenParent = lines.length - shownParent.length;
267
- if (hiddenParent > 0) out.push(`<i>✓ +${hiddenParent} earlier…</i>`);
268
- for (const l of shownParent) out.push(`<i>✓ ${escapeFeedHtml(l)}</i>`);
269
-
270
- const shownChild = children.slice(-NESTED_MAX_LINES);
271
- const hiddenChild = children.length - shownChild.length;
272
- if (hiddenChild > 0) out.push(`${NESTED_PREFIX}<i>+${hiddenChild} earlier…</i>`);
273
- const lastChildIdx = shownChild.length - 1;
274
- // `final`: the nested newest step also renders done (✓) so the left-behind
275
- // feed reads as completed, not stuck on a "→ in-progress" child step.
276
- // `liveSuffix` (heartbeat): appended to the nested newest in-progress step.
277
- shownChild.forEach((l, i) => {
278
- const t = l.length > NESTED_LINE_MAX ? l.slice(0, NESTED_LINE_MAX - 1) + "…" : l;
279
- const esc = escapeFeedHtml(t);
280
- out.push(
281
- i === lastChildIdx && !final
282
- ? `${NESTED_PREFIX}<b>→ ${esc}${liveSuffix}</b>`
283
- : `${NESTED_PREFIX}<i>${esc}</i>`,
284
- );
285
- });
286
- return out.length > 0 ? out.join("\n") : null;
587
+ if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix, stepCount, header);
588
+ return renderStatusCard({
589
+ header: header != null
590
+ ? {
591
+ emoji: '🤖',
592
+ label: header.label,
593
+ elapsedMs: header.elapsedMs,
594
+ toolCount: header.toolCount,
595
+ state: header.state,
596
+ }
597
+ : undefined,
598
+ steps: lines,
599
+ childSteps: children,
600
+ final,
601
+ liveSuffix,
602
+ stepCount,
603
+ })
287
604
  }
288
605
 
289
606
  /**