switchroom 0.19.42 → 0.19.43

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 (72) hide show
  1. package/dist/agent-scheduler/index.js +1 -1
  2. package/dist/auth-broker/index.js +32 -7
  3. package/dist/cli/notion-write-pretool.mjs +1 -1
  4. package/dist/cli/switchroom.js +1187 -669
  5. package/dist/host-control/main.js +205 -28
  6. package/dist/vault/approvals/kernel-server.js +32 -7
  7. package/dist/vault/broker/server.js +32 -7
  8. package/package.json +3 -3
  9. package/profiles/_base/start.sh.hbs +82 -4
  10. package/telegram-plugin/bridge/bridge.ts +1 -1
  11. package/telegram-plugin/card-layout.ts +63 -1
  12. package/telegram-plugin/dist/bridge/bridge.js +4 -1
  13. package/telegram-plugin/dist/gateway/gateway.js +1225 -614
  14. package/telegram-plugin/dist/server.js +5 -2
  15. package/telegram-plugin/flood-429-ledger.ts +5 -3
  16. package/telegram-plugin/flood-circuit-breaker.ts +11 -3
  17. package/telegram-plugin/format.ts +223 -13
  18. package/telegram-plugin/gateway/boot-card.ts +7 -2
  19. package/telegram-plugin/gateway/flood-reply-queue.ts +5 -0
  20. package/telegram-plugin/gateway/gateway.ts +49 -51
  21. package/telegram-plugin/gateway/handback-orphan-recovery.ts +69 -0
  22. package/telegram-plugin/gateway/handback-preturn-signal.ts +170 -32
  23. package/telegram-plugin/gateway/ipc-protocol.ts +31 -3
  24. package/telegram-plugin/gateway/ipc-server.ts +13 -4
  25. package/telegram-plugin/gateway/obligation-store.ts +55 -0
  26. package/telegram-plugin/gateway/outbound-send-path.ts +127 -11
  27. package/telegram-plugin/gateway/outbox-sweep.ts +371 -61
  28. package/telegram-plugin/gateway/rollout-status-edit.ts +175 -0
  29. package/telegram-plugin/gateway/stream-render.ts +5 -0
  30. package/telegram-plugin/gateway/update-announce.ts +11 -4
  31. package/telegram-plugin/hooks/audience-classify.d.mts +50 -0
  32. package/telegram-plugin/hooks/audience-classify.mjs +364 -0
  33. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +86 -3
  34. package/telegram-plugin/hooks/silent-end-scan.mjs +80 -4
  35. package/telegram-plugin/outbox.ts +103 -2
  36. package/telegram-plugin/render/rich-render.ts +10 -2
  37. package/telegram-plugin/reply-quote.ts +150 -0
  38. package/telegram-plugin/retry-api-call.ts +254 -11
  39. package/telegram-plugin/shared/bot-runtime.ts +69 -34
  40. package/telegram-plugin/shared/gw-trace-gate.ts +14 -2
  41. package/telegram-plugin/silent-end.ts +34 -2
  42. package/telegram-plugin/status-no-truncate.ts +31 -16
  43. package/telegram-plugin/stream-controller.ts +44 -9
  44. package/telegram-plugin/stream-reply-handler.ts +5 -3
  45. package/telegram-plugin/tests/boot-card-render.test.ts +2 -1
  46. package/telegram-plugin/tests/card-budget-never-overflows.test.ts +165 -0
  47. package/telegram-plugin/tests/card-hard-truncate-entity-safe.test.ts +153 -0
  48. package/telegram-plugin/tests/card-variants.golden.txt +9 -9
  49. package/telegram-plugin/tests/flood-reply-queue.test.ts +89 -4
  50. package/telegram-plugin/tests/format-consistency.test.ts +160 -14
  51. package/telegram-plugin/tests/handback-orphan-recovery.test.ts +131 -0
  52. package/telegram-plugin/tests/handback-preturn-signal.test.ts +212 -17
  53. package/telegram-plugin/tests/nested-indent-idiom.test.ts +162 -0
  54. package/telegram-plugin/tests/nested-prefix-indent.test.ts +115 -0
  55. package/telegram-plugin/tests/no-robust-api-call-factory.test.ts +62 -0
  56. package/telegram-plugin/tests/obligation-store.test.ts +51 -0
  57. package/telegram-plugin/tests/outbox-audience-3865.test.ts +579 -0
  58. package/telegram-plugin/tests/outbox-provenance-4141.test.ts +1126 -0
  59. package/telegram-plugin/tests/outbox-sweep-single-flight.test.ts +138 -0
  60. package/telegram-plugin/tests/pinned-card-collapse.test.ts +12 -10
  61. package/telegram-plugin/tests/plain-fallback-4096-cap.test.ts +509 -0
  62. package/telegram-plugin/tests/reply-quote-wire.test.ts +433 -0
  63. package/telegram-plugin/tests/retry-grammy-http-error.test.ts +404 -0
  64. package/telegram-plugin/tests/rollout-narration-edit-socket.test.ts +170 -0
  65. package/telegram-plugin/tests/rollout-status-edit-retry-policy.test.ts +366 -0
  66. package/telegram-plugin/tests/rollout-status-edit.test.ts +140 -0
  67. package/telegram-plugin/tests/rollout-status-wiring.test.ts +40 -7
  68. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +4 -0
  69. package/telegram-plugin/tests/tg-post-retry-attribution.test.ts +196 -0
  70. package/telegram-plugin/tests/tool-activity-summary.test.ts +23 -17
  71. package/telegram-plugin/tool-activity-summary.ts +58 -30
  72. package/telegram-plugin/uat/scenarios/jtbd-foreground-subagent-activity-dm.test.ts +3 -2
@@ -0,0 +1,196 @@
1
+ /**
2
+ * A reply that was rate-limited and then DELIVERED on retry must not raise a
3
+ * fleet-health delivery-failure alert (#3931).
4
+ *
5
+ * ── The bug ──────────────────────────────────────────────────────────────
6
+ * `installTgPostLogger` is a grammy API transformer, so it runs INSIDE the
7
+ * function the retry policy calls — it observes one POST ATTEMPT, never the
8
+ * logical send's outcome. A 429 that `createRetryApiCall` slept and retried
9
+ * successfully therefore left a `tg-post method=sendRichMessage … status=err`
10
+ * line in the gateway log, and fleet-health's `reply-delivery-failure`
11
+ * signature (`src/fleet-health/detect.ts`) matches per LINE. Result: a
12
+ * severity-3 "the answer never reached the principal" escalation for an answer
13
+ * the operator had already read. Under flood pressure (the class of event that
14
+ * produces 429s in the first place) that is the alarm that fires most.
15
+ *
16
+ * ── The fix these tests pin ──────────────────────────────────────────────
17
+ * The retry policy publishes its attempt context; the transformer labels an
18
+ * attempt the policy is about to repeat `status=retry`. `status=err` now means
19
+ * the logical send is OVER and nothing landed. Nothing is suppressed — the
20
+ * retry lines are still written, they just no longer masquerade as outcomes.
21
+ *
22
+ * These tests drive a REAL grammy `Bot` (a mock-`api` harness sits above the
23
+ * transformer layer, so it would be a false guard) through the REAL retry
24
+ * policy, and feed the REAL emitted lines to the REAL detector.
25
+ */
26
+ import { describe, it, expect, vi, afterEach } from 'vitest'
27
+ import { Bot } from 'grammy'
28
+ import { installTgPostLogger } from '../shared/bot-runtime.js'
29
+ import { createRetryApiCall, willRetryTelegramFailure } from '../retry-api-call.js'
30
+ import { detectGatewayFindings, scanAgent } from '../../src/fleet-health/detect.js'
31
+
32
+ /** A real Bot whose transport answers with `envelopes[i]` for the i-th call. */
33
+ function makeBot(envelopes: Array<Record<string, unknown>>): Bot {
34
+ let i = 0
35
+ const fakeFetch = (async () => {
36
+ const envelope = envelopes[Math.min(i, envelopes.length - 1)]
37
+ i++
38
+ // A Bot API rejection is an HTTP 200 carrying `ok:false` — which is
39
+ // precisely why it RESOLVES the transformer chain (see #3927).
40
+ return { ok: true, status: 200, json: async () => envelope } as unknown as Response
41
+ }) as unknown as typeof fetch
42
+
43
+ const bot = new Bot('123456:TEST_TOKEN', {
44
+ botInfo: {
45
+ id: 123456,
46
+ is_bot: true,
47
+ first_name: 'Test',
48
+ username: 'test_bot',
49
+ can_join_groups: false,
50
+ can_read_all_group_messages: false,
51
+ supports_inline_queries: false,
52
+ can_connect_to_business: false,
53
+ has_main_web_app: false,
54
+ },
55
+ client: { fetch: fakeFetch },
56
+ })
57
+ installTgPostLogger(bot)
58
+ return bot
59
+ }
60
+
61
+ const OK_ENVELOPE = {
62
+ ok: true,
63
+ result: { message_id: 7, date: 0, chat: { id: 4242, type: 'private' } },
64
+ }
65
+ const FLOOD_429 = {
66
+ ok: false,
67
+ error_code: 429,
68
+ description: 'Too Many Requests: retry after 3',
69
+ parameters: { retry_after: 3 },
70
+ }
71
+
72
+ /**
73
+ * Send one user-facing reply through the production composition
74
+ * (retry policy → transformer → wire) and return the gateway log lines it
75
+ * produced, shaped exactly as `gateway-supervisor.log` carries them.
76
+ */
77
+ async function gatewayLinesForReply(
78
+ envelopes: Array<Record<string, unknown>>,
79
+ ): Promise<{ lines: string[]; threw: boolean }> {
80
+ const written: string[] = []
81
+ const spy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
82
+ written.push(String(chunk))
83
+ return true
84
+ }) as unknown as typeof process.stderr.write)
85
+ let threw = false
86
+ try {
87
+ const bot = makeBot(envelopes)
88
+ // The real policy, with sleeping stubbed out so a 3s flood-wait doesn't
89
+ // cost the suite 3 seconds. Everything else is production behaviour.
90
+ const robustApiCall = createRetryApiCall({ sleep: async () => {} })
91
+ await robustApiCall(() =>
92
+ bot.api.sendRichMessage(4242, { markdown: 'the answer' }),
93
+ ).catch(() => {
94
+ threw = true
95
+ })
96
+ } finally {
97
+ spy.mockRestore()
98
+ }
99
+ const lines = written
100
+ .filter((l) => l.startsWith('tg-post '))
101
+ .map((l) => `2026-08-01T12:00:00Z gateway: ${l.trimEnd()}`)
102
+ return { lines, threw }
103
+ }
104
+
105
+ afterEach(() => {
106
+ vi.restoreAllMocks()
107
+ })
108
+
109
+ describe('#3931 — a retried-and-delivered reply raises no delivery-failure alert', () => {
110
+ it('429-then-ok: fleet-health sees zero reply-delivery-failures and does not escalate', async () => {
111
+ const { lines, threw } = await gatewayLinesForReply([FLOOD_429, OK_ENVELOPE])
112
+
113
+ // Ground truth: the reply WAS delivered. If this ever fails, the fixture
114
+ // stopped modelling the scenario and the assertions below mean nothing.
115
+ expect(threw).toBe(false)
116
+ expect(lines).toHaveLength(2)
117
+ expect(lines[1]).toContain('status=ok')
118
+
119
+ // THE outcome: the detector raises nothing, and the agent is not escalated.
120
+ const { gw_hits, findings } = detectGatewayFindings('alpha', lines.join('\n'))
121
+ expect(gw_hits['reply-delivery-failure']).toBe(0)
122
+ expect(findings.filter((f) => f.signal === 'reply-delivery-failure')).toHaveLength(0)
123
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(false)
124
+
125
+ // The evidence is not hidden — the rate-limit attempt is still on the log,
126
+ // honestly labelled as an attempt rather than an outcome.
127
+ expect(lines[0]).toContain('status=retry')
128
+ expect(lines[0]).toContain('code=429')
129
+ })
130
+
131
+ it('an ok:true reply is untouched — no retry tier leaks onto the happy path', async () => {
132
+ const { lines, threw } = await gatewayLinesForReply([OK_ENVELOPE])
133
+ expect(threw).toBe(false)
134
+ expect(lines).toHaveLength(1)
135
+ expect(lines[0]).toContain('status=ok')
136
+ expect(lines[0]).toContain('err=- code=- desc=-')
137
+ })
138
+ })
139
+
140
+ describe('#3931 — a genuinely undelivered reply still escalates', () => {
141
+ it('a terminal 403 escalates', async () => {
142
+ const { lines, threw } = await gatewayLinesForReply([
143
+ { ok: false, error_code: 403, description: 'Forbidden: bot was blocked by the user' },
144
+ ])
145
+ expect(threw).toBe(true)
146
+ expect(lines).toHaveLength(1)
147
+ expect(lines[0]).toContain('status=err')
148
+
149
+ expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
150
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
151
+ })
152
+
153
+ it('a 429 on EVERY attempt (retries exhausted, nothing landed) escalates exactly once', async () => {
154
+ const { lines, threw } = await gatewayLinesForReply([FLOOD_429])
155
+ expect(threw).toBe(true)
156
+ // Three attempts: two survivable, the last one terminal.
157
+ expect(lines).toHaveLength(3)
158
+ expect(lines.filter((l) => l.includes('status=retry'))).toHaveLength(2)
159
+ expect(lines.filter((l) => l.includes('status=err'))).toHaveLength(1)
160
+
161
+ // One alert for one lost answer — not one per attempt.
162
+ expect(detectGatewayFindings('alpha', lines.join('\n')).gw_hits['reply-delivery-failure']).toBe(1)
163
+ expect(scanAgent('alpha', '', lines.join('\n')).escalate).toBe(true)
164
+ })
165
+ })
166
+
167
+ describe('willRetryTelegramFailure — the predicate mirrors the retry loop', () => {
168
+ const ctx = { attempt: 0, maxRetries: 3, maxFloodSleepMs: 120_000 }
169
+
170
+ it('is false with no enclosing retry policy (a raw bot.api.* call is its own outcome)', () => {
171
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, undefined)).toBe(false)
172
+ })
173
+
174
+ it('is false on the last attempt — the loop falls through to give-up', () => {
175
+ expect(
176
+ willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, { ...ctx, attempt: 2 }),
177
+ ).toBe(false)
178
+ })
179
+
180
+ it('is false for a 429 over the in-process sleep ceiling (FLOOD_WAIT_ACTIVE, not retried)', () => {
181
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 15908 }, ctx)).toBe(false)
182
+ })
183
+
184
+ it('is true for a 429 under the ceiling and for a transient transport error', () => {
185
+ expect(willRetryTelegramFailure({ errorCode: 429, retryAfterSec: 3 }, ctx)).toBe(true)
186
+ expect(willRetryTelegramFailure({ message: 'ECONNRESET' }, ctx)).toBe(true)
187
+ })
188
+
189
+ it('is false for the error classes the loop rethrows immediately', () => {
190
+ expect(willRetryTelegramFailure({ errorCode: 400, message: 'Bad Request' }, ctx)).toBe(false)
191
+ expect(willRetryTelegramFailure({ errorCode: 403 }, ctx)).toBe(false)
192
+ // Local disk exhaustion must NOT look retryable — retrying it is what
193
+ // trips the flood ban in the first place (#2923).
194
+ expect(willRetryTelegramFailure({ message: 'ENOSPC: no space left on device' }, ctx)).toBe(false)
195
+ })
196
+ })
@@ -14,7 +14,13 @@ import {
14
14
  STEP_TIMER_MIN_MS,
15
15
  type SessionActivityHeader,
16
16
  } from "../tool-activity-summary.js";
17
- import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from "../status-no-truncate.js";
17
+ import { STATUS_ROLLING_LINES, STATUS_LINE_MAX, NESTED_PREFIX } from "../status-no-truncate.js";
18
+
19
+ // The nested (foreground sub-agent) child-block indent. Asserted through the
20
+ // constant, never as a literal: #3668 replaced its ASCII spaces with U+2800
21
+ // (Telegram left-trims a leading ASCII/Zs run off a content line), and a test
22
+ // that hardcodes the bytes pins the defect instead of the behaviour.
23
+ const NP = NESTED_PREFIX;
18
24
  import { COLLAPSE_SAFE_SEPARATOR } from "../card-format.js";
19
25
 
20
26
  /** The pinned-card collapse separator (#3666) every status-card line now
@@ -352,29 +358,29 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
352
358
  expect(out).toContain("~~_✓ Delegating: review the migration_~~");
353
359
  expect(out).not.toContain("**→ Delegating");
354
360
  // The live → step is the newest nested child line; earlier child = italic.
355
- expect(out).toContain("~~_Reading schema.ts_~~");
356
- expect(out).toContain("**→ Looking for foreign keys**");
361
+ expect(out).toContain(NP + "~~_Reading schema.ts_~~");
362
+ expect(out).toContain(NP + "**→ Looking for foreign keys**");
357
363
  });
358
364
 
359
365
  it("windows the nested block to STATUS_ROLLING_LINES with a '↳ +N earlier…' header", () => {
360
366
  const total = STATUS_ROLLING_LINES + 3;
361
367
  const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
362
368
  const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
363
- expect(out).toContain(` ↳ _+${total - STATUS_ROLLING_LINES} earlier…_`);
369
+ expect(out).toContain(`${NP}_+${total - STATUS_ROLLING_LINES} earlier…_`);
364
370
  // newest nested line is the live → step
365
- expect(out).toContain(` ↳ **→ step ${total}**`);
371
+ expect(out).toContain(`${NP}**→ step ${total}**`);
366
372
  // the oldest (collapsed) lines are not rendered verbatim
367
373
  expect(out).not.toContain("step 1<");
368
374
  });
369
375
 
370
376
  it("renders the child block even when the parent feed is empty", () => {
371
377
  const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
372
- expect(out).toBe("**→ Reading a.ts**");
378
+ expect(out).toBe(NP + "**→ Reading a.ts**");
373
379
  });
374
380
 
375
381
  it("markdown-escapes nested child text (emphasis specials only)", () => {
376
382
  const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
377
- expect(out).toContain("**→ touch a\\_b & 2 \\* 3**");
383
+ expect(out).toContain(NP + "**→ touch a\\_b & 2 \\* 3**");
378
384
  });
379
385
 
380
386
  it("final=true: the nested newest step renders done (✓), not in-progress (→)", () => {
@@ -383,7 +389,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
383
389
  ["Reading schema.ts", "Looking for foreign keys"],
384
390
  true,
385
391
  )!;
386
- expect(out).toContain("~~_Looking for foreign keys_~~"); // newest now struck italic done
392
+ expect(out).toContain(NP + "~~_Looking for foreign keys_~~"); // newest now struck italic done
387
393
  expect(out).not.toContain("→"); // no in-progress arrow in the finalized feed
388
394
  });
389
395
 
@@ -400,7 +406,7 @@ describe("renderActivityFeedWithNested — foreground sub-agent nesting (Model A
400
406
  false,
401
407
  " · 22s",
402
408
  )!;
403
- expect(out).toContain("**→ Looking for foreign keys · 22s**");
409
+ expect(out).toContain(NP + "**→ Looking for foreign keys · 22s**");
404
410
  expect(out).not.toContain("Reading schema.ts · "); // only the newest line ticks
405
411
  });
406
412
 
@@ -567,7 +573,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
567
573
  expect(out).not.toContain(`Parent ${i}`);
568
574
  }
569
575
  expect(out).toContain(`_✓ +${totalParent - STATUS_ROLLING_LINES} earlier…_`);
570
- expect(out).toContain("**→ Child step B**");
576
+ expect(out).toContain(NP + "**→ Child step B**");
571
577
  });
572
578
 
573
579
  it("many child lines → only the last STATUS_ROLLING_LINES child lines render with a ↳ +N earlier header", () => {
@@ -582,8 +588,8 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
582
588
  for (let i = 1; i < firstVisible; i++) {
583
589
  expect(out).not.toContain(`Child ${i}`);
584
590
  }
585
- expect(out).toContain(` ↳ _+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
586
- expect(out).toContain(` ↳ **→ Child ${totalChild}**`);
591
+ expect(out).toContain(`${NP}_+${totalChild - STATUS_ROLLING_LINES} earlier…_`);
592
+ expect(out).toContain(`${NP}**→ Child ${totalChild}**`);
587
593
  });
588
594
 
589
595
  it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
@@ -600,7 +606,7 @@ describe("rolling window + +N earlier — renderActivityFeedWithNested", () => {
600
606
  const child = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine);
601
607
  const out = renderActivityFeedWithNested(parent, child)!;
602
608
  expect(out.length).toBeLessThanOrEqual(4096);
603
- expect(out).toContain(" ↳ ");
609
+ expect(out).toContain(NP);
604
610
  });
605
611
  });
606
612
 
@@ -678,11 +684,11 @@ describe("extreme-edge: single oversized line with markdown specials & && _ *",
678
684
  expect(out.length).toBeLessThanOrEqual(4000);
679
685
  expect(isValidMarkdown(out)).toBe(true);
680
686
  // Must contain the nested prefix.
681
- expect(out).toContain(" ↳ ");
687
+ expect(out).toContain(NP);
682
688
  // Regression guard: an operator-precedence bug made wrapperOverhead a string
683
689
  // (NESTED_PREFIX + n) → NaN budget → slice(0, NaN) === "" → the child content
684
- // was silently discarded, leaving only " ↳ **→ **". The empty-wrapper
685
- // output still satisfies the toContain(" ↳ ") check above, so assert that
690
+ // was silently discarded, leaving only `${NESTED_PREFIX}**→ **`. The empty-wrapper
691
+ // output still satisfies the toContain(NP) check above, so assert that
686
692
  // real child content actually survives.
687
693
  expect(out).toContain("Child step");
688
694
  expect(out.length).toBeGreaterThan(100);
@@ -1025,7 +1031,7 @@ describe("renderActivityFeed — header param (main-session card fix)", () => {
1025
1031
  // Parent step is done-styled (child is the live step).
1026
1032
  expect(out).toContain("~~_✓ Delegating: review_~~");
1027
1033
  // Child step is the in-progress step.
1028
- expect(out).toContain("**→ Reading schema.ts**");
1034
+ expect(out).toContain(NP + "**→ Reading schema.ts**");
1029
1035
  });
1030
1036
  });
1031
1037
 
@@ -389,13 +389,14 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
389
389
  * an already-escaped markdown escape is never sliced.
390
390
  */
391
391
  const deepest = Math.max(1, escapedBody.length)
392
+ /** The collapsed parent trail, kept at every shrink level below level 0. */
393
+ const markerSection: CardSection = {
394
+ steps: [],
395
+ window: 1,
396
+ placeholder: parentMarker ?? undefined,
397
+ }
392
398
  return fitCardToBudget((level) => {
393
399
  if (level === 0) return { spec: fullSpec() }
394
- const markerSection: CardSection = {
395
- steps: [],
396
- window: 1,
397
- placeholder: parentMarker ?? undefined,
398
- }
399
400
  if (level < escapedBody.length) {
400
401
  return {
401
402
  spec: {
@@ -427,39 +428,66 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
427
428
  }
428
429
  }, deepest)
429
430
 
431
+ /** The deepest shrink level's spec, for a given already-wrapped body line. */
432
+ function deepestSpec(bodyLine: string): CardSpec {
433
+ return {
434
+ chrome,
435
+ sections: [markerSection, { steps: [], window: 1, placeholder: bodyLine }],
436
+ footer,
437
+ }
438
+ }
439
+
440
+ /** Wrap an ALREADY-ESCAPED newest step in its bullet chrome. */
441
+ function wrapNewest(escaped: string): string {
442
+ return final
443
+ ? `${bodyIndent}_✓ ${escaped}_`
444
+ : `${bodyIndent}**→ ${escaped}${liveSuffix}**`
445
+ }
446
+
430
447
  /**
431
- * The deepest shrink level's single bullet. Charges the fixed header/footer
432
- * (and the parent marker) against `STATUS_CARD_CHAR_BUDGET`, then clips the
433
- * RAW newest text to what is left, re-checking after escaping because
434
- * escaping can expand the string (`&` → `&amp;`).
448
+ * The deepest shrink level's single bullet: clip the RAW newest text to
449
+ * whatever the fixed chrome/footer leaves of `STATUS_CARD_CHAR_BUDGET`,
450
+ * escape, then re-measure escaping can expand the string (`&` → `&amp;`).
451
+ *
452
+ * Everything is measured by RENDERING the level's real spec (#3833). The
453
+ * previous arithmetic estimate charged `[...chrome, ...footer].join('\n')`,
454
+ * but the renderer is `stackCard`, which emits a collapse separator plus a
455
+ * two-space GFM hard break at every line boundary — 4 chars, not 1 — and it
456
+ * also charges the marker line and the section boundaries the estimate only
457
+ * partly counted. That under-count let a card render OVER the wire budget.
458
+ * Measuring with the renderer cannot drift from it by construction.
435
459
  */
436
460
  function truncatedNewestLine(): string {
437
- const fixedCost = [...chrome, ...footer].join('\n').length
438
461
  const rawNewest = rawBody.length > 0 ? cleanStepLine(rawBody[rawBody.length - 1]) : ''
439
- const wrapperOverhead = final
440
- ? (bodyIndent + '_✓ _').length
441
- : (bodyIndent + '**→ **').length + liveSuffix.length
442
- const headerFooterCost =
443
- fixedCost +
444
- (fixedCost > 0 ? 1 : 0) +
445
- (parentMarker != null ? parentMarker.length + 1 : 0)
446
- const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
447
- let raw = rawNewest.slice(0, Math.max(0, budget))
448
- let newest = escapeMarkdown(raw)
449
- while (
450
- raw.length > 0 &&
451
- wrapperOverhead + headerFooterCost + newest.length > STATUS_CARD_CHAR_BUDGET
452
- ) {
453
- const excess = wrapperOverhead + headerFooterCost + newest.length - STATUS_CARD_CHAR_BUDGET
454
- raw = raw.slice(0, Math.max(0, raw.length - excess - 1))
455
- newest = escapeMarkdown(raw)
462
+ const empty = wrapNewest('')
463
+ const fixedCost = renderCardSpec(deepestSpec(empty)).length
464
+ const budget = STATUS_CARD_CHAR_BUDGET - fixedCost
465
+ // Chrome alone is over budget (a 40k-char worker description, #3682). No
466
+ // amount of body clipping helps; `fitCardToBudget`'s hard-truncate backstop
467
+ // is what keeps the card under the wire limit in that case.
468
+ if (budget <= 0) return empty
469
+ let raw = clipRaw(rawNewest, budget)
470
+ let line = wrapNewest(escapeMarkdown(raw))
471
+ for (;;) {
472
+ const excess = renderCardSpec(deepestSpec(line)).length - STATUS_CARD_CHAR_BUDGET
473
+ if (excess <= 0 || raw.length === 0) return line
474
+ // Dropping k RAW chars drops at least k escaped chars (escaping never
475
+ // shrinks), so this strictly converges.
476
+ raw = clipRaw(raw, Math.max(0, raw.length - excess))
477
+ line = wrapNewest(escapeMarkdown(raw))
456
478
  }
457
- return final
458
- ? `${bodyIndent}_✓ ${newest}_`
459
- : `${bodyIndent}**→ ${newest}${liveSuffix}**`
460
479
  }
461
480
  }
462
481
 
482
+ /**
483
+ * Slice raw (pre-escape) text to `n` chars without leaving a dangling lone
484
+ * high surrogate — an unpaired surrogate is invalid UTF-16 on the wire.
485
+ */
486
+ function clipRaw(text: string, n: number): string {
487
+ const out = text.slice(0, Math.max(0, n))
488
+ return /[\uD800-\uDBFF]$/.test(out) ? out.slice(0, -1) : out
489
+ }
490
+
463
491
  /** Subtle horizontal rule between the running feed and the finished result. */
464
492
  const WORKER_RESULT_RULE = '─────'
465
493
  /** Hard cap on the terminal result paragraph. */
@@ -51,8 +51,9 @@ const FG_DISPATCH_PROMPT =
51
51
  `one-line summary." Wait for the foreground sub-agent to finish, then send ` +
52
52
  `me a brief reply telling me it's done.`;
53
53
 
54
- // The nested foreground block renders each child line prefixed with "↳"
55
- // (NESTED_PREFIX = " "), newest as a bold "→ …" current step. Telegram
54
+ // The nested foreground block renders each child line prefixed with a three
55
+ // U+2800 indent then "↳" (NESTED_PREFIX; #3668 replaced the old ASCII spaces,
56
+ // which Telegram left-trims), newest as a bold "→ …" current step. Telegram
56
57
  // strips the bold but keeps the literal ↳ / → glyphs in message text.
57
58
  const NESTED_RE = /↳/;
58
59