switchroom 0.19.26 → 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 (77) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +55 -0
  3. package/dist/auth-broker/index.js +116 -6
  4. package/dist/cli/notion-write-pretool.mjs +55 -0
  5. package/dist/cli/switchroom.js +2055 -1193
  6. package/dist/host-control/main.js +117 -7
  7. package/dist/vault/approvals/kernel-server.js +114 -6
  8. package/dist/vault/broker/server.js +114 -6
  9. package/package.json +6 -2
  10. package/profiles/_base/cron-session.sh.hbs +8 -0
  11. package/profiles/_base/start.sh.hbs +105 -0
  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 +2009 -1166
  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 +58 -68
  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/pinned-message-handler.ts +12 -16
  25. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  26. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  27. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  28. package/telegram-plugin/llm-error-present.ts +61 -2
  29. package/telegram-plugin/model-unavailable.ts +8 -0
  30. package/telegram-plugin/operator-events.ts +72 -5
  31. package/telegram-plugin/outbound-class.ts +81 -0
  32. package/telegram-plugin/provider-credit.ts +237 -0
  33. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  34. package/telegram-plugin/send-gate.ts +24 -2
  35. package/telegram-plugin/status-no-truncate.ts +10 -48
  36. package/telegram-plugin/status-pin-driver.ts +33 -45
  37. package/telegram-plugin/status-pin.ts +18 -1
  38. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  39. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  40. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  41. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  42. package/telegram-plugin/tests/card-variants.ts +366 -0
  43. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +316 -0
  44. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  45. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  46. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  47. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  48. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  49. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  50. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  51. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  52. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  53. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  54. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  55. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  56. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  57. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  58. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  59. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  60. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  61. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  62. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  63. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  64. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  65. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  66. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  67. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  68. package/telegram-plugin/tool-activity-summary.ts +239 -365
  69. package/telegram-plugin/uat/assertions.ts +22 -11
  70. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  71. package/telegram-plugin/worker-activity-feed.ts +105 -47
  72. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  73. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  74. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  75. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  76. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  77. package/vendor/hindsight-memory/settings.json +1 -1
@@ -90,12 +90,46 @@ import {
90
90
  STATUS_LINE_MAX,
91
91
  NESTED_PREFIX,
92
92
  WORKER_STEP_INDENT,
93
- SUBORDINATE_LINE_INDENT,
94
- nestSubordinateCardLines,
95
93
  } from './status-no-truncate.js'
96
- import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
94
+ import { escapeMarkdown, truncate } from './card-format.js'
97
95
  import { isTelegramSurfaceTool } from './tool-names.js'
98
- 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 }
99
133
 
100
134
  /**
101
135
  * Optional header for the main-session activity card, matching the worker
@@ -167,92 +201,6 @@ export function clipNarrative(s: string): string {
167
201
  return s.split('\n')[0].trim().slice(0, STATUS_LINE_MAX);
168
202
  }
169
203
 
170
- /**
171
- * Render a two-line header for the activity card, matching the worker card's
172
- * style. Used by both the main-session card and the worker card.
173
- *
174
- * Line 1: `<emoji> <b>Label</b> · <i>description</i>` (description optional)
175
- * Line 2: status + elapsed + tool count
176
- *
177
- * `emoji` — leading emoji (e.g. "🤖", "🛠")
178
- * `label` — bold name (e.g. "Agent", "Worker")
179
- * `description` — italicised task description (optional)
180
- * `elapsedMs` — wall-clock elapsed, rendered via `formatFeedElapsed`
181
- * `toolCount` — labeled tool calls this turn
182
- * `state` — 'running' | 'done' | 'failed' | 'incomplete' (controls the
183
- * status line wording; 'failed'/'incomplete' render
184
- * `failed · …` / `incomplete · …` so a failed or reaped worker
185
- * never reads as done)
186
- *
187
- * Returns a two-element array of ready Telegram HTML lines (no trailing newline).
188
- */
189
- export function renderActivityHeader(
190
- emoji: string,
191
- label: string,
192
- description: string,
193
- elapsedMs: number,
194
- toolCount: number,
195
- state: 'running' | 'done' | 'failed' | 'incomplete',
196
- model?: string,
197
- totalTokens?: number,
198
- ): [string, string] {
199
- const toolWord = toolCount === 1 ? 'tool' : 'tools'
200
- const elapsed = formatFeedElapsed(elapsedMs)
201
- const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
202
- const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
203
- // Running total tokens: joins the dot-separated metrics between the tool
204
- // count and the model tag. Omitted (empty) when the total is 0/unknown.
205
- const tokPart = tokenSegment(totalTokens)
206
- // Subtle live-model tag: joins the existing dot-separated metrics (never a new
207
- // line). formatModelLabel returns null for absent/sentinel values → no suffix.
208
- const modelLabel = formatModelLabel(model)
209
- const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
210
- const line2 = state === 'running'
211
- ? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
212
- : `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
213
- return [line1, line2]
214
- }
215
-
216
- /**
217
- * Compact token-count formatter for the activity card's metrics line:
218
- * <1000 → raw ("940")
219
- * ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
220
- * ≥1e6 → one-decimal M ("1.2M")
221
- * Negative / non-finite inputs clamp to "0". The caller appends " tok".
222
- */
223
- export function formatTokenCount(n: number): string {
224
- if (!Number.isFinite(n) || n <= 0) return '0'
225
- if (n < 1000) return String(Math.floor(n))
226
- if (n < 1_000_000) {
227
- // Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
228
- // round to "1000.0k", which must promote into the M branch rather than
229
- // render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
230
- const k = Number((n / 1000).toFixed(1))
231
- if (k < 1000) return `${k.toFixed(1)}k`
232
- }
233
- return `${(n / 1_000_000).toFixed(1)}M`
234
- }
235
-
236
- /**
237
- * The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
238
- * A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
239
- * transcript) OMITS the segment entirely so the line stays clean; the same
240
- * predicate is used by BOTH render variants (single-worker header + combined
241
- * row) so they never diverge.
242
- */
243
- function tokenSegment(totalTokens: number | undefined): string {
244
- if (totalTokens == null || totalTokens <= 0) return ''
245
- return ` · ${formatTokenCount(totalTokens)} tok`
246
- }
247
-
248
- /** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
249
- export function formatFeedElapsed(ms: number): string {
250
- const s = Math.floor(ms / 1000)
251
- if (s < 60) return `${s}s`
252
- const m = Math.floor(s / 60)
253
- return `${m}m${(s % 60).toString().padStart(2, '0')}s`
254
- }
255
-
256
204
  /**
257
205
  * Minimum time the CURRENT step must have been running before its own
258
206
  * `· <elapsed>` suffix appears on the `→` line. Under this, no suffix — a
@@ -272,62 +220,6 @@ export function formatStepSuffix(stepElapsedMs: number): string {
272
220
  return ` · ${formatFeedElapsed(stepElapsedMs)}`
273
221
  }
274
222
 
275
- // ─── Truncation pipeline (the single correctness-critical primitive) ────────
276
- //
277
- // Per RAW line, in this EXACT order:
278
- // 1. stripMarkdown(raw)
279
- // 2. .replace(/\s+/g, ' ').trim()
280
- // 3. truncate(_, STATUS_LINE_MAX)
281
- // 4. escapeMarkdown(_) ← escape is ALWAYS the last per-line op.
282
- // Escaping last is load-bearing: clipping an already-escaped string can split
283
- // a markdown escape (\* → \), which renders wrong.
284
-
285
- /** Clean + clip + escape a single raw step line. Returns ready-to-wrap markdown. */
286
- function escapeStepLine(raw: string): string {
287
- const cleaned = stripMarkdown(raw).replace(/\s+/g, ' ').trim()
288
- return escapeMarkdown(truncate(cleaned, STATUS_LINE_MAX))
289
- }
290
-
291
- /**
292
- * Shared step-feed emitter. Appends `✓`/`→` bullet lines to `out` for the
293
- * given ALREADY-ESCAPED step strings, windowing to `window` (default
294
- * STATUS_ROLLING_LINES; the worker surfaces pass a deeper window) and
295
- * prepending a `+N earlier…` header when the feed overflows the window (on
296
- * BOTH surfaces now). The worker feed imports this directly.
297
- *
298
- * `out` — accumulator mutated in place
299
- * `steps` — pre-cleaned + pre-escaped HTML step strings
300
- * `allDone` — when true ALL steps render done (✓ struck italic); when false the
301
- * newest renders in-progress (→ bold)
302
- * `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
303
- * `indent` — literal prefix put on EVERY emitted line (incl. the
304
- * `+N earlier…` header), OUTSIDE the markdown spans so it never
305
- * lands inside an emphasis run. Default `''` — byte-identical
306
- * output for callers that don't indent. The combined worker card
307
- * passes `WORKER_STEP_INDENT` to nest steps under their worker.
308
- */
309
- export function renderStepFeed(
310
- out: string[],
311
- steps: string[],
312
- allDone: boolean,
313
- liveSuffix = '',
314
- window: number = STATUS_ROLLING_LINES,
315
- indent = '',
316
- ): void {
317
- if (steps.length === 0) return
318
- const shown = steps.slice(-Math.max(1, window))
319
- const hidden = steps.length - shown.length
320
- if (hidden > 0) out.push(`${indent}_✓ +${hidden} earlier…_`)
321
- const lastIdx = shown.length - 1
322
- shown.forEach((s, i) => {
323
- out.push(
324
- !allDone && i === lastIdx
325
- ? `${indent}**→ ${s}${liveSuffix}**`
326
- : `${indent}~~_✓ ${s}_~~`,
327
- )
328
- })
329
- }
330
-
331
223
  // ─── Unified status-card primitive ──────────────────────────────────────────
332
224
  //
333
225
  // Both status surfaces (🤖 agent + 🛠 worker) render through `renderStatusCard`.
@@ -375,17 +267,16 @@ export interface StatusCardOpts {
375
267
  */
376
268
  historyWindow?: number
377
269
  /**
378
- * Render this card as structurally SUBORDINATE to the 🤖 agent card (#3820):
379
- * line 1 is prefixed with `└─ ` and every later line with
380
- * `SUBORDINATE_LINE_INDENT`, so the whole block sits one level in from the
381
- * agent card's left margin.
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.
382
272
  *
383
- * This is what makes "my agent" vs "a background worker" answerable from the
384
- * card's SHAPE without reading its label the two cards were otherwise
385
- * byte-for-byte the same layout, differing only by an emoji + one word.
386
- * Worker surfaces pass `true`; the agent card never does.
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.
387
278
  */
388
- subordinate?: boolean
279
+ emptyPlaceholder?: string
389
280
  }
390
281
 
391
282
  /**
@@ -395,8 +286,13 @@ export interface StatusCardOpts {
395
286
  * Pipeline: header → escape+clip every raw step/child → rolling-window each
396
287
  * group with `+N earlier…` → wrap (parent done-styled when children present;
397
288
  * newest-in-progress `→` bold else `✓` italic; NESTED_PREFIX for children) →
398
- * optional `✓ N steps` footer → optional result block → subordinate nesting
399
- * (#3820, when `opts.subordinate`) → fitCardToBudget.
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.
400
296
  */
401
297
  export function renderStatusCard(opts: StatusCardOpts): string | null {
402
298
  const { header, final = false, liveSuffix = '', stepCount, result } = opts
@@ -409,7 +305,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
409
305
  const steps = rawSteps.map(escapeStepLine)
410
306
  const children = rawChildren.map(escapeStepLine)
411
307
 
412
- const headerLines = header != null
308
+ const chrome = header != null
413
309
  ? renderActivityHeader(
414
310
  header.emoji,
415
311
  header.label,
@@ -426,145 +322,139 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
426
322
  )
427
323
  : []
428
324
 
429
- const out: string[] = [...headerLines]
430
-
431
- if (hasChildren) {
432
- // Parent lines all render done — the live → step lives in the nested block.
433
- const shownParent = steps.slice(-window)
434
- const hiddenParent = steps.length - shownParent.length
435
- if (hiddenParent > 0) out.push(`_✓ +${hiddenParent} earlier…_`)
436
- for (const s of shownParent) out.push(`~~_✓ ${s}_~~`)
437
- // Child block.
438
- const shownChild = children.slice(-window)
439
- const hiddenChild = children.length - shownChild.length
440
- if (hiddenChild > 0) out.push(`${NESTED_PREFIX}_+${hiddenChild} earlier…_`)
441
- const lastChildIdx = shownChild.length - 1
442
- shownChild.forEach((s, i) => {
443
- out.push(
444
- i === lastChildIdx && !final
445
- ? `${NESTED_PREFIX}**→ ${s}${liveSuffix}**`
446
- : `${NESTED_PREFIX}~~_${s}_~~`,
447
- )
448
- })
449
- } else {
450
- renderStepFeed(out, steps, final, liveSuffix, window)
451
- }
452
-
453
- if (final && stepCount != null && stepCount > 0) {
454
- out.push(`_✓ ${stepCount} steps_`)
455
- }
456
-
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_`)
457
328
  if (result != null && result.text.length > 0) {
458
- out.push(WORKER_RESULT_RULE)
459
- 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))}_`)
460
331
  }
461
332
 
462
- // out always carries the two header lines, so it is never empty — but guard
463
- // against a degenerate header-less future caller.
464
- if (out.length === 0) return null
465
- // Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
466
- // don't collapse onto one visual line in the rich-message renderer — see
467
- // stackCardLines. This is what makes a card render identically to a reply.
468
- // collapseSafe (#3666): the agent status card and the single-worker card are
469
- // both PINNED, and Telegram's pinned bar shows them collapsed to one line
470
- // with the newlines dropped — the separator keeps that preview legible.
471
- const joined = stackCardLines(opts.subordinate === true ? nestSubordinateCardLines(out) : out, {
472
- collapseSafe: true,
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,
473
359
  })
474
- if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
475
- return fitCardToBudget(opts, headerLines)
476
- }
477
-
478
- /** Subtle horizontal rule between the running feed and the finished result. */
479
- const WORKER_RESULT_RULE = '─────'
480
- /** Hard cap on the terminal result paragraph. */
481
- const WORKER_RESULT_MAX = 320
482
-
483
- /**
484
- * Char-budget backstop. Keeps the header / footer / result block fixed and
485
- * drops the oldest body bullets one at a time, re-inserting a `+N earlier…`
486
- * marker, until the card fits STATUS_CARD_CHAR_BUDGET. In the extreme case
487
- * (a single newest bullet that is itself oversized) it truncates the RAW
488
- * newest text, THEN escapes, THEN wraps — never slicing already-escaped markdown.
489
- */
490
- function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
491
- const { final = false, liveSuffix = '', stepCount, result } = opts
492
- const rawSteps = opts.steps.filter((s) => s != null)
493
- const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
494
- const hasChildren = rawChildren.length > 0
495
- const subordinate = opts.subordinate === true
496
- // Subordinate nesting (#3820) costs a fixed prefix on EVERY line — the header
497
- // prefix and the body indent are the same length by invariant (see
498
- // SUBORDINATE_HEADER_PREFIX), so it is one flat per-line charge rather than a
499
- // line-1 special case. Charged into the arithmetic below so the extreme
500
- // single-oversized-bullet branch still lands under the wire cap.
501
- const nestCost = subordinate ? SUBORDINATE_LINE_INDENT.length : 0
502
- const stack = (lines: string[]): string =>
503
- stackCardLines(subordinate ? nestSubordinateCardLines(lines) : lines, { collapseSafe: true })
504
-
505
- // Fixed footer/result lines (always kept).
506
- const footerLines: string[] = []
507
- if (final && stepCount != null && stepCount > 0) footerLines.push(`_✓ ${stepCount} steps_`)
508
- if (result != null && result.text.length > 0) {
509
- footerLines.push(WORKER_RESULT_RULE)
510
- footerLines.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
511
- }
512
- const fixedCost =
513
- [...headerLines, ...footerLines].join('\n').length +
514
- (headerLines.length + footerLines.length) * nestCost
515
360
 
516
- // The active "body" group whose oldest bullets we drop: children when
517
- // present (parent collapses to a single "+N" marker), else parent steps.
518
- const body = hasChildren ? rawChildren : rawSteps
519
- const escapedBody = body.map(escapeStepLine)
520
- const prefix = hasChildren ? NESTED_PREFIX : ''
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
521
365
 
522
- const buildBullet = (esc: string, isLast: boolean): string =>
523
- !final && isLast ? `${prefix}**→ ${esc}${liveSuffix}**` : `${prefix}_${esc}_`
524
-
525
- // 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).
526
368
  const parentMarker =
527
369
  hasChildren && rawSteps.length > 0 ? `_✓ +${rawSteps.length} earlier…_` : null
528
370
 
529
- for (let drop = 1; drop < escapedBody.length; drop++) {
530
- const shown = escapedBody.slice(drop)
531
- const lines: string[] = [...headerLines]
532
- if (parentMarker != null) lines.push(parentMarker)
533
- lines.push(hasChildren ? `${NESTED_PREFIX}_+${drop} earlier…_` : `_✓ +${drop} earlier…_`)
534
- const lastIdx = shown.length - 1
535
- shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
536
- lines.push(...footerLines)
537
- // collapseSafe: same pinned surface as renderStatusCard (#3666).
538
- const candidate = stack(lines)
539
- if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
540
- }
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)
541
419
 
542
- // Extreme: the single newest bullet is itself oversized. Truncate the RAW
543
- // newest text, then escape, then wrap re-checking post-escape because
544
- // escaping can expand the string (& &amp;).
545
- const rawNewest = body.length > 0 ? stripMarkdown(body[body.length - 1]).replace(/\s+/g, ' ').trim() : ''
546
- const wrapperOverhead =
547
- (final ? (prefix + '_✓ _').length : (prefix + '**→ **').length + liveSuffix.length) + nestCost
548
- const headerFooterCost =
549
- fixedCost +
550
- (fixedCost > 0 ? 1 : 0) +
551
- (parentMarker != null ? parentMarker.length + nestCost + 1 : 0)
552
- const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
553
- let raw = rawNewest.slice(0, Math.max(0, budget))
554
- let newest = escapeMarkdown(raw)
555
- while (raw.length > 0 && wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET) {
556
- const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
557
- raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
558
- 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}**`
559
450
  }
560
- const newestLine = final ? `${prefix}_✓ ${newest}_` : `${prefix}**→ ${newest}${liveSuffix}**`
561
- const lines: string[] = [...headerLines]
562
- if (parentMarker != null) lines.push(parentMarker)
563
- lines.push(newestLine)
564
- lines.push(...footerLines)
565
- return stack(lines)
566
451
  }
567
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
+
568
458
  /**
569
459
  * Render the accumulated feed as ready Telegram HTML — one action per line,
570
460
  * newest last. The current (newest) step is bold with a `→`; finished steps
@@ -796,10 +686,13 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
796
686
  const oldestMs = rows.reduce((m, r) => Math.max(m, r.elapsedMs), 0)
797
687
  const tools = rows.reduce((n, r) => n + r.toolCount, 0)
798
688
  const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
799
- 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.
800
693
  return (
801
694
  `🛠 **WORKERS** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
802
- ` · ${tools} ${toolWord}${tokenSegment(tok)}_`
695
+ ` · ${tools} ${toolWord(tools)}${tokenSegment(tok)}_`
803
696
  )
804
697
  }
805
698
 
@@ -807,23 +700,26 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
807
700
  * Render N≥1 live workers into ONE combined feed body (ready Telegram
808
701
  * markdown; callers send verbatim — do NOT re-escape). Layout:
809
702
  *
810
- * └─ 🛠 **WORKERS** · _N running · oldest {elapsed} · {n} tools · {t} tok_
811
- * **1. {desc1}** _· {elapsed} · {n} tools_
812
- * ~~_✓ {earlier step}_~~
813
- * **→ {newest step}**
814
- * **2. {desc2}** _· {elapsed} · {n} tools_
815
- * **→ {newest step}**
816
- * _+M more working…_
703
+ * 🛠 **WORKERS** · _N running · oldest {elapsed} · {n} tools · {t} tok_
704
+ * **1. {desc1}** _· {elapsed} · {n} tools_
705
+ * ~~_✓ {earlier step}_~~
706
+ * **→ {newest step}**
707
+ * **2. {desc2}** _· {elapsed} · {n} tools_
708
+ * **→ {newest step}**
709
+ * _+M more working…_
817
710
  *
818
- * SUBORDINATION (#3820): line 1 carries `└─ ` and every later line carries
819
- * `SUBORDINATE_LINE_INDENT`, so the whole card reads as a child block of the
820
- * 🤖 agent card rather than a second top-level card with the same silhouette.
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).
821
716
  *
822
- * INDENT: every step line carries a leading `WORKER_STEP_INDENT` (a U+2800 run
823
- * neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see the
824
- * constant's doc comment) so the steps nest under their worker header and the
825
- * per-worker blocks are scannable. Header and chrome lines stay at the left
826
- * margin.
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.
827
723
  *
828
724
  * NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
829
725
  * its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
@@ -869,91 +765,69 @@ export function renderCombinedWorkerFeed(
869
765
 
870
766
  const rowHeader = (r: CombinedWorkerRow): string => {
871
767
  const desc = escapeMarkdown(
872
- 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),
873
769
  )
874
- const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
875
- const tokPart = tokenSegment(r.totalTokens)
876
- const modelLabel = formatModelLabel(r.model)
877
- const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
878
770
  // Stable ordinal prefix INSIDE the bold span, before the already-escaped
879
771
  // description — no new escaping surface, and the gateway md→HTML conversion
880
772
  // has no ordered-list auto-formatting on bolded text.
881
773
  const num = numbered && r.ordinal != null ? `${r.ordinal}. ` : ''
882
- 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)}_`
883
779
  }
884
780
 
885
781
  // Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
886
782
  // Falls back to the single currentStep when no history was supplied.
887
783
  const rowHistory = (r: CombinedWorkerRow): string[] => {
888
784
  const src = r.historyLines != null && r.historyLines.length > 0 ? r.historyLines : [r.currentStep]
889
- 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)
890
786
  }
891
787
 
892
- const compose = (visibleCount: number): { body: string; bodyLines: number } => {
788
+ const compose = (visibleCount: number): CardCandidate => {
893
789
  const shown = rows.slice(0, visibleCount)
894
790
  const hidden = rows.length - shown.length
895
791
  // Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
896
792
  // the curve drives DEPTH; the total-line budget below drives ROW COUNT.
897
793
  const depth = combinedHistoryDepth(shown.length)
898
- const chrome: string[] = [glanceLine(rows)]
899
- const bodyOut: string[] = []
900
- for (const r of shown) {
901
- bodyOut.push(rowHeader(r))
902
- const hist = rowHistory(r)
903
- if (hist.length === 0) {
904
- bodyOut.push(`${WORKER_STEP_INDENT}→ _starting…_`)
905
- continue
906
- }
907
- // Paint the last-K history lines with the SAME `✓`/`→` idiom as the
908
- // single-worker card: escape each raw line through the shared per-line
909
- // pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
910
- // and bolds the newest in-progress step. The window equals the depth so a
911
- // per-worker `+N earlier…` marker never appears inside the combined feed.
912
- //
913
- // WORKER_STEP_INDENT nests every step line one level under its worker
914
- // header, so the boundary between two workers is visible at a glance on a
915
- // phone (the header lines stay at the left margin, their steps sit in).
916
- // It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
917
- // server-side parser left-trims a leading Unicode-whitespace run, so both
918
- // render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
919
- // not Zs. See the constant's doc comment for the live evidence.
920
- const esc = hist.slice(-depth).map(escapeStepLine)
921
- renderStepFeed(bodyOut, esc, false, '', depth, WORKER_STEP_INDENT)
922
- }
923
- const out = [...chrome, ...bodyOut]
924
- if (hidden > 0) out.push(`_+${hidden} more working…_`)
925
- // collapseSafe (#3666): this card is PINNED, and Telegram's pinned bar
926
- // renders it collapsed to one line with the newlines dropped and nothing
927
- // substituted. Without the separator the glance line runs straight into
928
- // row 1's ordinal and every step glyph mashes into the previous line.
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.
929
798
  //
930
- // Subordinate nesting (#3820) is applied LAST, over the finished line list:
931
- // the glance line takes `└─ ` and every row header / step / spill line takes
932
- // one `SUBORDINATE_LINE_INDENT`, so this card reads as a child block of the
933
- // 🤖 agent card the same way the single-worker card does. Step lines already
934
- // carry WORKER_STEP_INDENT, so they end up one level deeper than their row
935
- // header the intra-card hierarchy is preserved, just shifted right.
936
- return {
937
- body: stackCardLines(nestSubordinateCardLines(out), { collapseSafe: true }),
938
- bodyLines: bodyOut.length,
939
- }
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 }
940
822
  }
941
823
 
942
- // Cap to maxRows first, then shrink the visible set while EITHER the total
943
- // body-line budget (#3349: bounds a big swarm without stealing depth from the
944
- // shown workers) OR the wire char budget is exceeded. Newest (trailing) rows
945
- // collapse into the `+M more working…` spill (`rows.slice(0, visibleCount)`
946
- // keeps the head of the list).
947
- let visible = Math.min(rows.length, maxRows)
948
- let { body, bodyLines } = compose(visible)
949
- while (
950
- (bodyLines > MAX_COMBINED_BODY_LINES || body.length > STATUS_CARD_CHAR_BUDGET) &&
951
- visible > 1
952
- ) {
953
- visible -= 1
954
- ;({ body, bodyLines } = compose(visible))
955
- }
956
- 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)
957
831
  }
958
832
 
959
833
  /**