switchroom 0.19.25 → 0.19.27

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 (84) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +61 -2
  3. package/dist/auth-broker/index.js +125 -8
  4. package/dist/cli/notion-write-pretool.mjs +61 -2
  5. package/dist/cli/switchroom.js +2347 -1104
  6. package/dist/host-control/main.js +126 -9
  7. package/dist/vault/approvals/kernel-server.js +124 -8
  8. package/dist/vault/broker/server.js +124 -8
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +14 -0
  11. package/profiles/_base/start.sh.hbs +145 -4
  12. package/telegram-plugin/card-layout.ts +328 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +93 -1
  14. package/telegram-plugin/dist/gateway/gateway.js +2213 -1204
  15. package/telegram-plugin/dist/server.js +96 -1
  16. package/telegram-plugin/edit-flood-fuse.ts +637 -56
  17. package/telegram-plugin/flood-429-ledger.ts +526 -0
  18. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  20. package/telegram-plugin/gateway/gateway.ts +103 -112
  21. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  22. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  23. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  24. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  25. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  26. package/telegram-plugin/gateway/status-pin-retarget.ts +180 -0
  27. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  28. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  29. package/telegram-plugin/llm-error-present.ts +61 -2
  30. package/telegram-plugin/model-unavailable.ts +8 -0
  31. package/telegram-plugin/operator-events.ts +72 -5
  32. package/telegram-plugin/outbound-class.ts +81 -0
  33. package/telegram-plugin/provider-credit.ts +237 -0
  34. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  35. package/telegram-plugin/send-gate.ts +24 -2
  36. package/telegram-plugin/status-no-truncate.ts +11 -0
  37. package/telegram-plugin/status-pin-driver.ts +33 -17
  38. package/telegram-plugin/status-pin.ts +51 -5
  39. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  40. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  41. package/telegram-plugin/tests/card-type-distinguishability.test.ts +291 -0
  42. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  43. package/telegram-plugin/tests/card-variants.ts +366 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  45. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  46. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  47. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  48. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  49. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  50. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  51. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  52. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  53. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  54. package/telegram-plugin/tests/pinned-card-collapse.test.ts +24 -18
  55. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  56. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  57. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  58. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  59. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  60. package/telegram-plugin/tests/status-pin-retarget.test.ts +244 -0
  61. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  62. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  63. package/telegram-plugin/tests/status-pin-store.test.ts +179 -64
  64. package/telegram-plugin/tests/status-pin.test.ts +184 -7
  65. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  66. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  67. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +29 -19
  68. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  69. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  70. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  71. package/telegram-plugin/tier-downgrade.ts +3 -2
  72. package/telegram-plugin/tool-activity-summary.ts +239 -322
  73. package/telegram-plugin/uat/assertions.ts +33 -3
  74. package/telegram-plugin/uat/feed-matcher.test.ts +36 -0
  75. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  76. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  77. package/telegram-plugin/worker-activity-feed.ts +109 -30
  78. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  79. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  80. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  81. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  82. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  83. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
  84. package/vendor/hindsight-memory/settings.json +1 -1
@@ -91,9 +91,45 @@ import {
91
91
  NESTED_PREFIX,
92
92
  WORKER_STEP_INDENT,
93
93
  } from './status-no-truncate.js'
94
- import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
94
+ import { escapeMarkdown, truncate } from './card-format.js'
95
95
  import { isTelegramSurfaceTool } from './tool-names.js'
96
- import { formatModelLabel } from './model-label.js'
96
+ // The card layout core. Header composition (`metricsRun` /
97
+ // `renderActivityHeader`), the per-line escape pipeline (`escapeStepLine`), the
98
+ // bullet emitter (`emitSection`) and the char-budget backstop
99
+ // (`fitCardToBudget`) each live there exactly once; the renderers below are
100
+ // CONFIGURATIONS of it, never parallel implementations (#3844).
101
+ import {
102
+ cardSpecLines,
103
+ cleanStepLine,
104
+ escapeStepLine,
105
+ emitSection,
106
+ fitCardToBudget,
107
+ formatFeedElapsed,
108
+ formatTokenCount,
109
+ metricsRun,
110
+ renderActivityHeader,
111
+ renderCardSpec,
112
+ renderCardTitleLine,
113
+ renderStepFeed,
114
+ tokenSegment,
115
+ toolWord,
116
+ type CardCandidate,
117
+ type CardSection,
118
+ type CardSpec,
119
+ type CardState,
120
+ } from './card-layout.js'
121
+
122
+ // Re-exported so every existing importer keeps its `tool-activity-summary.js`
123
+ // entrypoint while the implementations live in the one layout core.
124
+ export {
125
+ escapeStepLine,
126
+ formatFeedElapsed,
127
+ formatTokenCount,
128
+ renderActivityHeader,
129
+ renderCardTitleLine,
130
+ renderStepFeed,
131
+ }
132
+ export type { CardSection, CardSpec, CardState }
97
133
 
98
134
  /**
99
135
  * Optional header for the main-session activity card, matching the worker
@@ -165,92 +201,6 @@ export function clipNarrative(s: string): string {
165
201
  return s.split('\n')[0].trim().slice(0, STATUS_LINE_MAX);
166
202
  }
167
203
 
168
- /**
169
- * Render a two-line header for the activity card, matching the worker card's
170
- * style. Used by both the main-session card and the worker card.
171
- *
172
- * Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
173
- * Line 2: status + elapsed + tool count
174
- *
175
- * `emoji` — leading emoji (e.g. "🤖", "🛠")
176
- * `label` — bold name (e.g. "Agent", "Worker")
177
- * `description` — italicised task description (optional)
178
- * `elapsedMs` — wall-clock elapsed, rendered via `formatFeedElapsed`
179
- * `toolCount` — labeled tool calls this turn
180
- * `state` — 'running' | 'done' | 'failed' | 'incomplete' (controls the
181
- * status line wording; 'failed'/'incomplete' render
182
- * `failed · …` / `incomplete · …` so a failed or reaped worker
183
- * never reads as done)
184
- *
185
- * Returns a two-element array of ready Telegram HTML lines (no trailing newline).
186
- */
187
- export function renderActivityHeader(
188
- emoji: string,
189
- label: string,
190
- description: string,
191
- elapsedMs: number,
192
- toolCount: number,
193
- state: 'running' | 'done' | 'failed' | 'incomplete',
194
- model?: string,
195
- totalTokens?: number,
196
- ): [string, string] {
197
- const toolWord = toolCount === 1 ? 'tool' : 'tools'
198
- const elapsed = formatFeedElapsed(elapsedMs)
199
- const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
200
- const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
201
- // Running total tokens: joins the dot-separated metrics between the tool
202
- // count and the model tag. Omitted (empty) when the total is 0/unknown.
203
- const tokPart = tokenSegment(totalTokens)
204
- // Subtle live-model tag: joins the existing dot-separated metrics (never a new
205
- // line). formatModelLabel returns null for absent/sentinel values → no suffix.
206
- const modelLabel = formatModelLabel(model)
207
- const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
208
- const line2 = state === 'running'
209
- ? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
210
- : `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
211
- return [line1, line2]
212
- }
213
-
214
- /**
215
- * Compact token-count formatter for the activity card's metrics line:
216
- * <1000 → raw ("940")
217
- * ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
218
- * ≥1e6 → one-decimal M ("1.2M")
219
- * Negative / non-finite inputs clamp to "0". The caller appends " tok".
220
- */
221
- export function formatTokenCount(n: number): string {
222
- if (!Number.isFinite(n) || n <= 0) return '0'
223
- if (n < 1000) return String(Math.floor(n))
224
- if (n < 1_000_000) {
225
- // Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
226
- // round to "1000.0k", which must promote into the M branch rather than
227
- // render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
228
- const k = Number((n / 1000).toFixed(1))
229
- if (k < 1000) return `${k.toFixed(1)}k`
230
- }
231
- return `${(n / 1_000_000).toFixed(1)}M`
232
- }
233
-
234
- /**
235
- * The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
236
- * A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
237
- * transcript) OMITS the segment entirely so the line stays clean; the same
238
- * predicate is used by BOTH render variants (single-worker header + combined
239
- * row) so they never diverge.
240
- */
241
- function tokenSegment(totalTokens: number | undefined): string {
242
- if (totalTokens == null || totalTokens <= 0) return ''
243
- return ` · ${formatTokenCount(totalTokens)} tok`
244
- }
245
-
246
- /** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
247
- export function formatFeedElapsed(ms: number): string {
248
- const s = Math.floor(ms / 1000)
249
- if (s < 60) return `${s}s`
250
- const m = Math.floor(s / 60)
251
- return `${m}m${(s % 60).toString().padStart(2, '0')}s`
252
- }
253
-
254
204
  /**
255
205
  * Minimum time the CURRENT step must have been running before its own
256
206
  * `· <elapsed>` suffix appears on the `→` line. Under this, no suffix — a
@@ -270,62 +220,6 @@ export function formatStepSuffix(stepElapsedMs: number): string {
270
220
  return ` · ${formatFeedElapsed(stepElapsedMs)}`
271
221
  }
272
222
 
273
- // ─── Truncation pipeline (the single correctness-critical primitive) ────────
274
- //
275
- // Per RAW line, in this EXACT order:
276
- // 1. stripMarkdown(raw)
277
- // 2. .replace(/\s+/g, ' ').trim()
278
- // 3. truncate(_, STATUS_LINE_MAX)
279
- // 4. escapeMarkdown(_) ← escape is ALWAYS the last per-line op.
280
- // Escaping last is load-bearing: clipping an already-escaped string can split
281
- // a markdown escape (\* → \), which renders wrong.
282
-
283
- /** Clean + clip + escape a single raw step line. Returns ready-to-wrap markdown. */
284
- function escapeStepLine(raw: string): string {
285
- const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
286
- return escapeMarkdown(truncate(cleaned, STATUS_LINE_MAX))
287
- }
288
-
289
- /**
290
- * Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
291
- * given ALREADY-ESCAPED step strings, windowing to `window` (default
292
- * STATUS_ROLLING_LINES; the worker surfaces pass a deeper window) and
293
- * prepending a `+N earlier…` header when the feed overflows the window (on
294
- * BOTH surfaces now). The worker feed imports this directly.
295
- *
296
- * `out` — accumulator mutated in place
297
- * `steps` — pre-cleaned + pre-escaped HTML step strings
298
- * `allDone` — when true ALL steps render done (✓ struck italic); when false the
299
- * newest renders in-progress (→ bold)
300
- * `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
301
- * `indent` — literal prefix put on EVERY emitted line (incl. the
302
- * `+N earlier…` header), OUTSIDE the markdown spans so it never
303
- * lands inside an emphasis run. Default `''` — byte-identical
304
- * output for callers that don't indent. The combined worker card
305
- * passes `WORKER_STEP_INDENT` to nest steps under their worker.
306
- */
307
- export function renderStepFeed(
308
- out: string[],
309
- steps: string[],
310
- allDone: boolean,
311
- liveSuffix = '',
312
- window: number = STATUS_ROLLING_LINES,
313
- indent = '',
314
- ): void {
315
- if (steps.length === 0) return
316
- const shown = steps.slice(-Math.max(1, window))
317
- const hidden = steps.length - shown.length
318
- if (hidden > 0) out.push(`${indent}_✓ +${hidden} earlier…_`)
319
- const lastIdx = shown.length - 1
320
- shown.forEach((s, i) => {
321
- out.push(
322
- !allDone && i === lastIdx
323
- ? `${indent}**→ ${s}${liveSuffix}**`
324
- : `${indent}~~_✓ ${s}_~~`,
325
- )
326
- })
327
- }
328
-
329
223
  // ─── Unified status-card primitive ──────────────────────────────────────────
330
224
  //
331
225
  // Both status surfaces (🤖 agent + 🛠 worker) render through `renderStatusCard`.
@@ -372,6 +266,17 @@ export interface StatusCardOpts {
372
266
  * full recent trail — see WORKER_HISTORY_MAX.
373
267
  */
374
268
  historyWindow?: number
269
+ /**
270
+ * Body line rendered when the card has NO steps and NO children at all — the
271
+ * 🛠 worker card's `starting…` placeholder for a just-dispatched worker.
272
+ *
273
+ * This used to be string-concatenated onto the renderer's return value by
274
+ * `renderWorkerActivity` (#3846), which put one user-visible card line
275
+ * outside the primitive that owns line assembly, hard-break stacking and the
276
+ * char budget. It is a normal body line now, emitted by `emitSection` like
277
+ * every other one.
278
+ */
279
+ emptyPlaceholder?: string
375
280
  }
376
281
 
377
282
  /**
@@ -382,6 +287,12 @@ export interface StatusCardOpts {
382
287
  * group with `+N earlier…` → wrap (parent done-styled when children present;
383
288
  * newest-in-progress `→` bold else `✓` italic; NESTED_PREFIX for children) →
384
289
  * optional `✓ N steps` footer → optional result block → fitCardToBudget.
290
+ *
291
+ * Every card this primitive emits is FLUSH at the left margin (#3842). The
292
+ * whole-card `└─ ` + one-level indent that #3820/#3821 put on the worker card
293
+ * is gone: it cost horizontal space on a phone and claimed a parent/child
294
+ * relationship that does not always hold. Intra-card nesting (the `↳` child
295
+ * block here, `WORKER_STEP_INDENT` on the combined card) is unaffected.
385
296
  */
386
297
  export function renderStatusCard(opts: StatusCardOpts): string | null {
387
298
  const { header, final = false, liveSuffix = '', stepCount, result } = opts
@@ -394,7 +305,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
394
305
  const steps = rawSteps.map(escapeStepLine)
395
306
  const children = rawChildren.map(escapeStepLine)
396
307
 
397
- const headerLines = header != null
308
+ const chrome = header != null
398
309
  ? renderActivityHeader(
399
310
  header.emoji,
400
311
  header.label,
@@ -411,131 +322,139 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
411
322
  )
412
323
  : []
413
324
 
414
- const out: string[] = [...headerLines]
415
-
416
- if (hasChildren) {
417
- // Parent lines all render done — the live → step lives in the nested block.
418
- const shownParent = steps.slice(-window)
419
- const hiddenParent = steps.length - shownParent.length
420
- if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
421
- for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
422
- // Child block.
423
- const shownChild = children.slice(-window)
424
- const hiddenChild = children.length - shownChild.length
425
- if (hiddenChild > 0) out.push(`${NESTED_PREFIX}_+${hiddenChild} earlier…_`)
426
- const lastChildIdx = shownChild.length - 1
427
- shownChild.forEach((s, i) => {
428
- out.push(
429
- i === lastChildIdx && !final
430
- ? `${NESTED_PREFIX}**→ ${s}${liveSuffix}**`
431
- : `${NESTED_PREFIX}~~_${s}_~~`,
432
- )
433
- })
434
- } else {
435
- renderStepFeed(out, steps, final, liveSuffix, window)
436
- }
437
-
438
- if (final && stepCount != null && stepCount > 0) {
439
- out.push(`_✓ ${stepCount} steps_`)
440
- }
441
-
325
+ // Fixed footer/result lines — kept at every shrink level.
326
+ const footer: string[] = []
327
+ if (final && stepCount != null && stepCount > 0) footer.push(`_✓ ${stepCount} steps_`)
442
328
  if (result != null && result.text.length > 0) {
443
- out.push(WORKER_RESULT_RULE)
444
- out.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
329
+ footer.push(WORKER_RESULT_RULE)
330
+ footer.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
445
331
  }
446
332
 
447
- // out always carries the two header lines, so it is never empty — but guard
448
- // against a degenerate header-less future caller.
449
- if (out.length === 0) return null
450
- // Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
451
- // don't collapse onto one visual line in the rich-message renderer — see
452
- // stackCardLines. This is what makes a card render identically to a reply.
453
- // collapseSafe (#3666): the agent status card and the single-worker card are
454
- // both PINNED, and Telegram's pinned bar shows them collapsed to one line
455
- // with the newlines dropped — the separator keeps that preview legible.
456
- const joined = stackCardLines(out, { collapseSafe: true })
457
- if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
458
- return fitCardToBudget(opts, headerLines)
459
- }
460
-
461
- /** Subtle horizontal rule between the running feed and the finished result. */
462
- const WORKER_RESULT_RULE = '─────'
463
- /** Hard cap on the terminal result paragraph. */
464
- const WORKER_RESULT_MAX = 320
465
-
466
- /**
467
- * Char-budget backstop. Keeps the header / footer / result block fixed and
468
- * drops the oldest body bullets one at a time, re-inserting a `+N earlier…`
469
- * marker, until the card fits STATUS_CARD_CHAR_BUDGET. In the extreme case
470
- * (a single newest bullet that is itself oversized) it truncates the RAW
471
- * newest text, THEN escapes, THEN wraps — never slicing already-escaped markdown.
472
- */
473
- function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
474
- const { final = false, liveSuffix = '', stepCount, result } = opts
475
- const rawSteps = opts.steps.filter((s) => s != null)
476
- const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
477
- const hasChildren = rawChildren.length > 0
478
-
479
- // Fixed footer/result lines (always kept).
480
- const footerLines: string[] = []
481
- if (final && stepCount != null && stepCount > 0) footerLines.push(`_✓ ${stepCount} steps_`)
482
- if (result != null && result.text.length > 0) {
483
- footerLines.push(WORKER_RESULT_RULE)
484
- footerLines.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
485
- }
486
- const fixedCost = [...headerLines, ...footerLines].join('\n').length
487
-
488
- // The active "body" group whose oldest bullets we drop: children when
489
- // present (parent collapses to a single "+N" marker), else parent steps.
490
- const body = hasChildren ? rawChildren : rawSteps
491
- const escapedBody = body.map(escapeStepLine)
492
- const prefix = hasChildren ? NESTED_PREFIX : ''
333
+ // The active "body" group the shrink levels erode: children when present
334
+ // (the parent trail collapses to a single `+N` marker), else parent steps.
335
+ const rawBody = hasChildren ? rawChildren : rawSteps
336
+ const escapedBody = hasChildren ? children : steps
337
+ const bodyIndent = hasChildren ? NESTED_PREFIX : ''
338
+ // The `↳` glyph already marks the nested block, so its bullets carry no ✓.
339
+ const bodyDoneMark = hasChildren ? '' : '✓ '
340
+
341
+ /** Level 0 — the full card, everything at its natural window. */
342
+ const fullSpec = (): CardSpec => ({
343
+ chrome,
344
+ sections: hasChildren
345
+ ? [
346
+ // Parent lines all render done — the live `→` lives in the nested block.
347
+ { steps, window, allDone: true },
348
+ {
349
+ steps: children,
350
+ window,
351
+ allDone: final,
352
+ liveSuffix,
353
+ indent: NESTED_PREFIX,
354
+ doneMark: '',
355
+ },
356
+ ]
357
+ : [{ steps, window, allDone: final, liveSuffix, placeholder: opts.emptyPlaceholder }],
358
+ footer,
359
+ })
493
360
 
494
- const buildBullet = (esc: string, isLast: boolean): string =>
495
- !final && isLast ? `${prefix}**→ ${esc}${liveSuffix}**` : `${prefix}_${esc}_`
361
+ const lines = cardSpecLines(fullSpec())
362
+ // `lines` always carries the two header lines, so it is never empty — but
363
+ // guard against a degenerate header-less future caller.
364
+ if (lines.length === 0) return null
496
365
 
497
- // Parent-collapsed marker line when we are dropping children but parent steps exist.
366
+ // Parent-collapsed marker line used once the shrink levels start dropping
367
+ // children (the parent trail is spent bytes on an over-budget card).
498
368
  const parentMarker =
499
369
  hasChildren && rawSteps.length > 0 ? `_✓ +${rawSteps.length} earlier…_` : null
500
370
 
501
- for (let drop = 1; drop < escapedBody.length; drop++) {
502
- const shown = escapedBody.slice(drop)
503
- const lines: string[] = [...headerLines]
504
- if (parentMarker != null) lines.push(parentMarker)
505
- lines.push(hasChildren ? `${NESTED_PREFIX}_+${drop} earlier…_` : `_✓ +${drop} earlier…_`)
506
- const lastIdx = shown.length - 1
507
- shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
508
- lines.push(...footerLines)
509
- // collapseSafe: same pinned surface as renderStatusCard (#3666).
510
- const candidate = stackCardLines(lines, { collapseSafe: true })
511
- if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
512
- }
371
+ /**
372
+ * Shrink levels for the status card:
373
+ * 0 — the full card.
374
+ * 1 … len(body)-1 — drop that many OLDEST body bullets, re-inserting a
375
+ * `+N earlier…` marker; done bullets lose their
376
+ * strike/✓ chrome (`doneStyle: 'plain'`).
377
+ * len(body) (deepest) — a single newest bullet that is ITSELF oversized:
378
+ * truncate the RAW text, THEN escape, THEN wrap, so
379
+ * an already-escaped markdown escape is never sliced.
380
+ */
381
+ const deepest = Math.max(1, escapedBody.length)
382
+ return fitCardToBudget((level) => {
383
+ if (level === 0) return { spec: fullSpec() }
384
+ const markerSection: CardSection = {
385
+ steps: [],
386
+ window: 1,
387
+ placeholder: parentMarker ?? undefined,
388
+ }
389
+ if (level < escapedBody.length) {
390
+ return {
391
+ spec: {
392
+ chrome,
393
+ sections: [
394
+ markerSection,
395
+ {
396
+ steps: escapedBody,
397
+ // Showing all but the `level` oldest — `emitSection` derives the
398
+ // `+level earlier…` marker from the hidden remainder.
399
+ window: escapedBody.length - level,
400
+ allDone: final,
401
+ liveSuffix,
402
+ indent: bodyIndent,
403
+ doneMark: bodyDoneMark,
404
+ doneStyle: 'plain',
405
+ },
406
+ ],
407
+ footer,
408
+ },
409
+ }
410
+ }
411
+ return {
412
+ spec: {
413
+ chrome,
414
+ sections: [markerSection, { steps: [], window: 1, placeholder: truncatedNewestLine() }],
415
+ footer,
416
+ },
417
+ }
418
+ }, deepest)
513
419
 
514
- // Extreme: the single newest bullet is itself oversized. Truncate the RAW
515
- // newest text, then escape, then wrap — re-checking post-escape because
516
- // escaping can expand the string (& → &amp;).
517
- const rawNewest = body.length > 0 ? stripMarkdown(body[body.length - 1]).replace(/\s+/g, ' ').trim() : ''
518
- const wrapperOverhead = final
519
- ? (prefix + '_✓ _').length
520
- : (prefix + '**→ **').length + liveSuffix.length
521
- const headerFooterCost =
522
- fixedCost + (fixedCost > 0 ? 1 : 0) + (parentMarker != null ? parentMarker.length + 1 : 0)
523
- const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
524
- let raw = rawNewest.slice(0, Math.max(0, budget))
525
- let newest = escapeMarkdown(raw)
526
- while (raw.length > 0 && wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET) {
527
- const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
528
- raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
529
- newest = escapeMarkdown(raw)
420
+ /**
421
+ * The deepest shrink level's single bullet. Charges the fixed header/footer
422
+ * (and the parent marker) against `STATUS_CARD_CHAR_BUDGET`, then clips the
423
+ * RAW newest text to what is left, re-checking after escaping because
424
+ * escaping can expand the string (`&` → `&amp;`).
425
+ */
426
+ function truncatedNewestLine(): string {
427
+ const fixedCost = [...chrome, ...footer].join('\n').length
428
+ const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
429
+ const wrapperOverhead = final
430
+ ? (bodyIndent + '_✓ _').length
431
+ : (bodyIndent + '**→ **').length + liveSuffix.length
432
+ const headerFooterCost =
433
+ fixedCost +
434
+ (fixedCost > 0 ? 1 : 0) +
435
+ (parentMarker != null ? parentMarker.length + 1 : 0)
436
+ const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
437
+ let raw = rawNewest.slice(0, Math.max(0, budget))
438
+ let newest = escapeMarkdown(raw)
439
+ while (
440
+ raw.length > 0 &&
441
+ wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET
442
+ ) {
443
+ const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
444
+ raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
445
+ newest = escapeMarkdown(raw)
446
+ }
447
+ return final
448
+ ? `${bodyIndent}_✓ ${newest}_`
449
+ : `${bodyIndent}**→ ${newest}${liveSuffix}**`
530
450
  }
531
- const newestLine = final ? `${prefix}_✓ ${newest}_` : `${prefix}**→ ${newest}${liveSuffix}**`
532
- const lines: string[] = [...headerLines]
533
- if (parentMarker != null) lines.push(parentMarker)
534
- lines.push(newestLine)
535
- lines.push(...footerLines)
536
- return stackCardLines(lines, { collapseSafe: true })
537
451
  }
538
452
 
453
+ /** Subtle horizontal rule between the running feed and the finished result. */
454
+ const WORKER_RESULT_RULE = '─────'
455
+ /** Hard cap on the terminal result paragraph. */
456
+ const WORKER_RESULT_MAX = 320
457
+
539
458
  /**
540
459
  * Render the accumulated feed as ready Telegram HTML — one action per line,
541
460
  * newest last. The current (newest) step is bold with a `→`; finished steps
@@ -767,10 +686,13 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
767
686
  const oldestMs = rows.reduce((m, r) => Math.max(m, r.elapsedMs), 0)
768
687
  const tools = rows.reduce((n, r) => n + r.toolCount, 0)
769
688
  const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
770
- const toolWord = tools === 1 ? 'tool' : 'tools'
689
+ // Aggregate glance, so it is NOT a `metricsRun` (no per-entity state/model,
690
+ // and it leads with the swarm count rather than elapsed). It still reads the
691
+ // same elapsed / tool-word / token primitives, so the vocabulary that DOES
692
+ // repeat across cards cannot drift.
771
693
  return (
772
- `🛠 **Workers** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
773
- ` · ${tools} ${toolWord}${tokenSegment(tok)}_`
694
+ `🛠 **WORKERS** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
695
+ ` · ${tools} ${toolWord(tools)}${tokenSegment(tok)}_`
774
696
  )
775
697
  }
776
698
 
@@ -778,7 +700,7 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
778
700
  * Render N≥1 live workers into ONE combined feed body (ready Telegram
779
701
  * markdown; callers send verbatim — do NOT re-escape). Layout:
780
702
  *
781
- * 🛠 **Workers** · _N running · oldest {elapsed} · {n} tools · {t} tok_
703
+ * 🛠 **WORKERS** · _N running · oldest {elapsed} · {n} tools · {t} tok_
782
704
  * **1. {desc1}** _· {elapsed} · {n} tools_
783
705
  * ~~_✓ {earlier step}_~~
784
706
  * **→ {newest step}**
@@ -786,11 +708,18 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
786
708
  * **→ {newest step}**
787
709
  * _+M more working…_
788
710
  *
789
- * INDENT: every step line carries a leading `WORKER_STEP_INDENT` (a U+2800 run
790
- * — neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see the
791
- * constant's doc comment) so the steps nest under their worker header and the
792
- * per-worker blocks are scannable. Header and chrome lines stay at the left
793
- * margin.
711
+ * FLUSH (#3842): the card sits at the left margin like every other card. The
712
+ * whole-card `└─ ` + one-level indent from #3820/#3821 is gone — it burned a
713
+ * level of horizontal phone width to assert a parent/child relationship with
714
+ * the 🤖 agent card that does not always hold (this card is not always below
715
+ * it).
716
+ *
717
+ * INDENT: exactly ONE level of indentation survives, and it is the level that
718
+ * earns its keep — every step line carries a leading `WORKER_STEP_INDENT` (a
719
+ * U+2800 run — neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see
720
+ * the constant's doc comment) so one worker's steps are visibly separated from
721
+ * the next worker's. The glance line, the numbered row headers and the spill
722
+ * line stay flush.
794
723
  *
795
724
  * NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
796
725
  * its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
@@ -836,81 +765,69 @@ export function renderCombinedWorkerFeed(
836
765
 
837
766
  const rowHeader = (r: CombinedWorkerRow): string => {
838
767
  const desc = escapeMarkdown(
839
- truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
768
+ truncate(cleanStepLine(r.description) || 'background task', COMBINED_ROW_DESC_MAX),
840
769
  )
841
- const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
842
- const tokPart = tokenSegment(r.totalTokens)
843
- const modelLabel = formatModelLabel(r.model)
844
- const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
845
770
  // Stable ordinal prefix INSIDE the bold span, before the already-escaped
846
771
  // description — no new escaping surface, and the gateway md→HTML conversion
847
772
  // has no ordered-list auto-formatting on bolded text.
848
773
  const num = numbered && r.ordinal != null ? `${r.ordinal}. ` : ''
849
- return `**${num}${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
774
+ // The metrics run is composed by the SAME function as the 🤖 agent header
775
+ // and the 🛠 single-worker header (`metricsRun`, card-layout.ts). A combined
776
+ // row is always live, so it reports 'running'; before #3844 this line spelled
777
+ // the run out by hand and agreed with the other two only by inspection.
778
+ return `**${num}${desc}** _· ${metricsRun(r.elapsedMs, r.toolCount, 'running', r.totalTokens, r.model)}_`
850
779
  }
851
780
 
852
781
  // Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
853
782
  // Falls back to the single currentStep when no history was supplied.
854
783
  const rowHistory = (r: CombinedWorkerRow): string[] => {
855
784
  const src = r.historyLines != null && r.historyLines.length > 0 ? r.historyLines : [r.currentStep]
856
- return src.filter((s) => s != null && stripMarkdown(s).replace(/\s+/g, ' ').trim().length > 0)
785
+ return src.filter((s) => s != null && cleanStepLine(s).length > 0)
857
786
  }
858
787
 
859
- const compose = (visibleCount: number): { body: string; bodyLines: number } => {
788
+ const compose = (visibleCount: number): CardCandidate => {
860
789
  const shown = rows.slice(0, visibleCount)
861
790
  const hidden = rows.length - shown.length
862
791
  // Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
863
792
  // the curve drives DEPTH; the total-line budget below drives ROW COUNT.
864
793
  const depth = combinedHistoryDepth(shown.length)
865
- const chrome: string[] = [glanceLine(rows)]
866
- const bodyOut: string[] = []
867
- for (const r of shown) {
868
- bodyOut.push(rowHeader(r))
869
- const hist = rowHistory(r)
870
- if (hist.length === 0) {
871
- bodyOut.push(`${WORKER_STEP_INDENT}→ _starting…_`)
872
- continue
873
- }
874
- // Paint the last-K history lines with the SAME `✓`/`→` idiom as the
875
- // single-worker card: escape each raw line through the shared per-line
876
- // pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
877
- // and bolds the newest in-progress step. The window equals the depth so a
878
- // per-worker `+N earlier…` marker never appears inside the combined feed.
879
- //
880
- // WORKER_STEP_INDENT nests every step line one level under its worker
881
- // header, so the boundary between two workers is visible at a glance on a
882
- // phone (the header lines stay at the left margin, their steps sit in).
883
- // It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
884
- // server-side parser left-trims a leading Unicode-whitespace run, so both
885
- // render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
886
- // not Zs. See the constant's doc comment for the live evidence.
887
- const esc = hist.slice(-depth).map(escapeStepLine)
888
- renderStepFeed(bodyOut, esc, false, '', depth, WORKER_STEP_INDENT)
889
- }
890
- const out = [...chrome, ...bodyOut]
891
- if (hidden > 0) out.push(`_+${hidden} more working…_`)
892
- // collapseSafe (#3666): this card is PINNED, and Telegram's pinned bar
893
- // renders it collapsed to one line with the newlines dropped and nothing
894
- // substituted. Without the separator the glance line runs straight into
895
- // row 1's ordinal and every step glyph mashes into the previous line.
896
- return { body: stackCardLines(out, { collapseSafe: true }), bodyLines: bodyOut.length }
794
+ // Each worker is ONE SECTION of the shared card spec: its row header, then
795
+ // its last-K history lines painted with the SAME `✓`/`→` idiom as every
796
+ // other card (emitSection). The window equals the depth, so a per-worker
797
+ // `+N earlier…` marker never appears inside the combined feed.
798
+ //
799
+ // WORKER_STEP_INDENT nests every step line one level under its worker
800
+ // header, so the boundary between two workers is visible at a glance on a
801
+ // phone (the header lines stay at the left margin, their steps sit in).
802
+ // It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
803
+ // server-side parser left-trims a leading Unicode-whitespace run, so both
804
+ // render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
805
+ // not Zs. See the constant's doc comment for the live evidence.
806
+ const sections: CardSection[] = shown.map((r) => ({
807
+ header: [rowHeader(r)],
808
+ steps: rowHistory(r).slice(-depth).map(escapeStepLine),
809
+ window: depth,
810
+ indent: WORKER_STEP_INDENT,
811
+ placeholder: '→ _starting…_',
812
+ }))
813
+ const footer = hidden > 0 ? [`_+${hidden} more working…_`] : []
814
+ // No whole-card nesting (#3842): the glance / row-header / spill lines land
815
+ // flush and only the step lines carry their WORKER_STEP_INDENT — one level
816
+ // of hierarchy, not two.
817
+ const spec: CardSpec = { chrome: [glanceLine(rows)], sections, footer }
818
+ // The per-worker BODY lines (row headers + their steps) — the glance line and
819
+ // the spill line are fixed chrome and sit outside this ceiling.
820
+ const bodyLines = cardSpecLines(spec).length - 1 - footer.length
821
+ return { spec, overflow: bodyLines > MAX_COMBINED_BODY_LINES }
897
822
  }
898
823
 
899
- // Cap to maxRows first, then shrink the visible set while EITHER the total
900
- // body-line budget (#3349: bounds a big swarm without stealing depth from the
901
- // shown workers) OR the wire char budget is exceeded. Newest (trailing) rows
902
- // collapse into the `+M more working…` spill (`rows.slice(0, visibleCount)`
903
- // keeps the head of the list).
904
- let visible = Math.min(rows.length, maxRows)
905
- let { body, bodyLines } = compose(visible)
906
- while (
907
- (bodyLines > MAX_COMBINED_BODY_LINES || body.length > STATUS_CARD_CHAR_BUDGET) &&
908
- visible > 1
909
- ) {
910
- visible -= 1
911
- ;({ body, bodyLines } = compose(visible))
912
- }
913
- return body
824
+ // Cap to maxRows first, then let the ONE shared budget enforcer shrink the
825
+ // visible set while EITHER the total body-line budget (#3349: bounds a big
826
+ // swarm without stealing depth from the shown workers) OR the wire char budget
827
+ // is exceeded. Newest (trailing) rows collapse into the `+M more working…`
828
+ // spill (`rows.slice(0, visibleCount)` keeps the head of the list).
829
+ const visible = Math.min(rows.length, maxRows)
830
+ return fitCardToBudget((level) => compose(Math.max(1, visible - level)), visible - 1)
914
831
  }
915
832
 
916
833
  /**