switchroom 0.19.25 β†’ 0.19.26

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 (35) hide show
  1. package/dist/agent-scheduler/index.js +6 -2
  2. package/dist/auth-broker/index.js +9 -2
  3. package/dist/cli/notion-write-pretool.mjs +6 -2
  4. package/dist/cli/switchroom.js +908 -527
  5. package/dist/host-control/main.js +10 -3
  6. package/dist/vault/approvals/kernel-server.js +10 -2
  7. package/dist/vault/broker/server.js +10 -2
  8. package/package.json +1 -1
  9. package/profiles/_base/cron-session.sh.hbs +6 -0
  10. package/profiles/_base/start.sh.hbs +40 -4
  11. package/telegram-plugin/dist/gateway/gateway.js +275 -109
  12. package/telegram-plugin/gateway/gateway.ts +53 -52
  13. package/telegram-plugin/gateway/periodic-sweep-guard.ts +86 -0
  14. package/telegram-plugin/gateway/status-pin-retarget.ts +144 -0
  15. package/telegram-plugin/status-no-truncate.ts +49 -0
  16. package/telegram-plugin/status-pin-driver.ts +28 -0
  17. package/telegram-plugin/status-pin.ts +33 -4
  18. package/telegram-plugin/tests/card-type-distinguishability.test.ts +268 -0
  19. package/telegram-plugin/tests/periodic-sweep-guard.test.ts +151 -0
  20. package/telegram-plugin/tests/pinned-card-collapse.test.ts +29 -18
  21. package/telegram-plugin/tests/status-pin-retarget.test.ts +216 -0
  22. package/telegram-plugin/tests/status-pin-shutdown-wiring.test.ts +94 -0
  23. package/telegram-plugin/tests/status-pin-store.test.ts +87 -21
  24. package/telegram-plugin/tests/status-pin.test.ts +128 -2
  25. package/telegram-plugin/tests/worker-activity-feed.test.ts +10 -10
  26. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +37 -21
  27. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  28. package/telegram-plugin/tier-downgrade.ts +3 -2
  29. package/telegram-plugin/tool-activity-summary.ts +61 -18
  30. package/telegram-plugin/uat/assertions.ts +21 -2
  31. package/telegram-plugin/uat/feed-matcher.test.ts +29 -0
  32. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-channel.test.ts +9 -2
  33. package/telegram-plugin/uat/scenarios/jtbd-liveness-narration-dm.test.ts +9 -2
  34. package/telegram-plugin/worker-activity-feed.ts +38 -17
  35. package/vendor/hindsight-memory/scripts/tests/test_recall_request_timeout.py +241 -0
@@ -6,7 +6,12 @@ import {
6
6
  type WorkerActivityView,
7
7
  } from '../worker-activity-feed.js'
8
8
  import { renderCombinedWorkerFeed, combinedHistoryDepth } from '../tool-activity-summary.js'
9
- import { STATUS_CARD_CHAR_BUDGET, WORKER_STEP_INDENT } from '../status-no-truncate.js'
9
+ import {
10
+ STATUS_CARD_CHAR_BUDGET,
11
+ WORKER_STEP_INDENT,
12
+ SUBORDINATE_HEADER_PREFIX,
13
+ SUBORDINATE_LINE_INDENT,
14
+ } from '../status-no-truncate.js'
10
15
  import { COLLAPSE_SAFE_SEPARATOR } from '../card-format.js'
11
16
  import { richMessage } from '../rich-send.js'
12
17
  import { parseRichEntities, validateRichMarkdown } from './rich-markdown-oracle.js'
@@ -661,7 +666,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
661
666
 
662
667
  it('renders one row-block per worker with a running count header', () => {
663
668
  const body = renderCombinedWorkerFeed([row(1, 'alpha step'), row(2, 'beta step')], { maxRows: 8 })!
664
- expect(body).toContain('Workers')
669
+ expect(body).toContain('WORKERS')
665
670
  expect(body).toContain('2 running')
666
671
  expect(body).toContain('task number 1')
667
672
  expect(body).toContain('task number 2')
@@ -756,7 +761,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
756
761
  .map((l) => l.trim())
757
762
  .filter((l) => l.length > 0)
758
763
  const headerAndHistory = bodyLines.filter(
759
- (l) => !l.startsWith('πŸ› ') && !l.includes('more working'),
764
+ (l) => !l.includes('**WORKERS**') && !l.includes('more working'),
760
765
  )
761
766
  expect(headerAndHistory.length).toBeLessThanOrEqual(24)
762
767
  })
@@ -775,7 +780,7 @@ describe('renderCombinedWorkerFeed (pure)', () => {
775
780
  .map((l) => l.trim())
776
781
  .filter((l) => l.length > 0)
777
782
  const headerAndHistory = bodyLines.filter(
778
- (l) => !l.startsWith('πŸ› ') && !l.includes('more working'),
783
+ (l) => !l.includes('**WORKERS**') && !l.includes('more working'),
779
784
  )
780
785
  expect(headerAndHistory.length).toBeLessThanOrEqual(24)
781
786
  expect(body).toContain('+2 more working')
@@ -1287,15 +1292,22 @@ describe('combined worker card β€” steps indent under their worker (U+2800)', ()
1287
1292
  // Load-bearing: the exact indent bytes. `WORKER_STEP_INDENT` is U+2800 Γ—3;
1288
1293
  // a plain-ASCII `' '` indent fails this assertion, so does the U+00A0 run
1289
1294
  // #3662 shipped, and so does flat output with no prefix at all.
1295
+ // Since #3820 the whole card is nested one level under the πŸ€– agent card,
1296
+ // so a step sits at SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT while its
1297
+ // worker header sits at SUBORDINATE_LINE_INDENT alone β€” the RELATIVE
1298
+ // one-level nesting this test guards is unchanged, just shifted right.
1290
1299
  for (const l of stepLines) {
1291
- expect(l.startsWith(WORKER_STEP_INDENT)).toBe(true)
1300
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(true)
1292
1301
  expect(l.startsWith(BLANK)).toBe(true)
1293
1302
  expect(l.startsWith(' ')).toBe(false)
1294
1303
  expect(l.startsWith(NBSP)).toBe(false)
1295
1304
  }
1296
- // Workers stay at the left margin so the nesting reads as nesting.
1297
- for (const l of headerLines) expect(l.startsWith(BLANK)).toBe(false)
1298
- expect(lines[0].startsWith('πŸ› ')).toBe(true)
1305
+ // Workers stay one level ABOVE their steps so the nesting reads as nesting.
1306
+ for (const l of headerLines) {
1307
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
1308
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(false)
1309
+ }
1310
+ expect(lines[0].startsWith(`${SUBORDINATE_HEADER_PREFIX}πŸ› `)).toBe(true)
1299
1311
  })
1300
1312
 
1301
1313
  it('golden body β€” each worker header is immediately followed by ITS indented steps', () => {
@@ -1305,7 +1317,8 @@ describe('combined worker card β€” steps indent under their worker (U+2800)', ()
1305
1317
  // would still pass every one of them. This pins the full line ORDER, which
1306
1318
  // is the actual thing the fix is for: a step must sit under its own worker.
1307
1319
  const body = renderCombinedWorkerFeed(rowsFor(3), { maxRows: 8 })!
1308
- const I = WORKER_STEP_INDENT
1320
+ const I = SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT
1321
+ const N = SUBORDINATE_LINE_INDENT
1309
1322
  // `S` is the pinned-bar collapse separator (#3666): every line that is
1310
1323
  // followed by a hard break carries one trailing U+00A0 so the pinned-message
1311
1324
  // bar (which drops the newline and substitutes nothing) does not mash the
@@ -1314,14 +1327,14 @@ describe('combined worker card β€” steps indent under their worker (U+2800)', ()
1314
1327
  // `WORKER_STEP_INDENT`, which is what this golden is really pinning.
1315
1328
  const S = COLLAPSE_SAFE_SEPARATOR
1316
1329
  expect(linesOf(body)).toEqual([
1317
- `πŸ›  **Workers** Β· _3 running Β· oldest 3m00s Β· 9 tools_${S}`,
1318
- `**1. worker task 1** _Β· 1m00s Β· 3 tools_${S}`,
1330
+ `${SUBORDINATE_HEADER_PREFIX}πŸ›  **WORKERS** Β· _3 running Β· oldest 3m00s Β· 9 tools_${S}`,
1331
+ `${N}**1. worker task 1** _Β· 1m00s Β· 3 tools_${S}`,
1319
1332
  `${I}~~_βœ“ w1 step a_~~${S}`,
1320
1333
  `${I}**β†’ w1 step b**${S}`,
1321
- `**2. worker task 2** _Β· 2m00s Β· 3 tools_${S}`,
1334
+ `${N}**2. worker task 2** _Β· 2m00s Β· 3 tools_${S}`,
1322
1335
  `${I}~~_βœ“ w2 step a_~~${S}`,
1323
1336
  `${I}**β†’ w2 step b**${S}`,
1324
- `**3. worker task 3** _Β· 3m00s Β· 3 tools_${S}`,
1337
+ `${N}**3. worker task 3** _Β· 3m00s Β· 3 tools_${S}`,
1325
1338
  `${I}~~_βœ“ w3 step a_~~${S}`,
1326
1339
  `${I}**β†’ w3 step b**`, // last line: nothing follows it to collide with
1327
1340
  ])
@@ -1442,7 +1455,7 @@ describe('combined worker card β€” steps indent under their worker (U+2800)', ()
1442
1455
  expect(ents.every((e) => !e.text.includes(NBSP))).toBe(true)
1443
1456
  })
1444
1457
 
1445
- it('the SINGLE-worker πŸ›  card is untouched (no indent on its step lines)', () => {
1458
+ it('the SINGLE-worker πŸ›  card nests ONE level (card-level only, no per-worker indent)', () => {
1446
1459
  const single = renderWorkerActivity({
1447
1460
  workerId: 'w1',
1448
1461
  description: 'lone worker',
@@ -1454,16 +1467,19 @@ describe('combined worker card β€” steps indent under their worker (U+2800)', ()
1454
1467
  })
1455
1468
  expect(single).toContain('~~_βœ“ step a_~~')
1456
1469
  expect(single).toContain('**β†’ step b**')
1457
- // No LEADING indent: the single-worker card has nothing to nest under, so
1458
- // its step lines must sit flush at the left margin. Asserted per line on the
1470
+ // The lone-worker card has no worker HEADER to nest steps under, so it must
1471
+ // never carry the per-worker indent β€” but since #3820 the whole card nests
1472
+ // ONE level under the πŸ€– agent card, so its body lines sit at exactly
1473
+ // SUBORDINATE_LINE_INDENT and never at two levels. Asserted per line on the
1459
1474
  // leading edge rather than as "no U+00A0 anywhere in the card", because
1460
1475
  // #3666 puts one TRAILING U+00A0 on every hard-broken line of every pinned
1461
- // card (this one included) as the collapse separator. A regression that
1462
- // leaked WORKER_STEP_INDENT onto these lines still fails here.
1463
- for (const l of single.split('\n')) {
1464
- expect(l.startsWith(BLANK)).toBe(false)
1476
+ // card (this one included) as the collapse separator.
1477
+ const ls = single.split('\n')
1478
+ expect(ls[0].startsWith(SUBORDINATE_HEADER_PREFIX)).toBe(true)
1479
+ for (const l of ls.slice(1)) {
1480
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT)).toBe(true)
1481
+ expect(l.startsWith(SUBORDINATE_LINE_INDENT + WORKER_STEP_INDENT)).toBe(false)
1465
1482
  expect(l.startsWith(NBSP)).toBe(false)
1466
- expect(l.startsWith(WORKER_STEP_INDENT)).toBe(false)
1467
1483
  }
1468
1484
  })
1469
1485
  })
@@ -253,7 +253,7 @@ describe('prose-silent background worker β€” end-to-end visibility harness (PR 2
253
253
  expect(h.bot.sent).toHaveLength(1)
254
254
  expect(h.feed.messageIdOf(h.agentId)).not.toBeNull()
255
255
  expect(h.bot.sent[0].chatId).toBe('chat-42')
256
- expect(h.bot.sent[0].text).toContain('πŸ›  **Worker**')
256
+ expect(h.bot.sent[0].text).toContain('└─ πŸ›  **WORKER**')
257
257
 
258
258
  // (b) KEEPS UPDATING: later heartbeats edit the message with a climbing
259
259
  // `Β· Ns` suffix so the still-alive worker visibly advances.
@@ -33,9 +33,10 @@
33
33
  *
34
34
  * Effort is NATIVE. A live `/effort` override records in gateway memory only
35
35
  * (no carrier), so the self-restart sheds it and the downgraded default boots at
36
- * the configured `thinking_effort` (the fleet `low` pin, #1978 /
36
+ * the configured `thinking_effort` (#1978 /
37
37
  * src/config/thinking-effort-risk.ts). This module writes NO effort carrier β€”
38
- * that is the whole point: the downgraded opus must resolve LOW.
38
+ * that is the whole point: the downgraded opus must resolve to the configured
39
+ * default, whatever that is.
39
40
  *
40
41
  * Loop guard β€” the natural on-default guard is the real bound. After the
41
42
  * downgrade boot the session runs the configured default (the override is gone
@@ -90,6 +90,8 @@ import {
90
90
  STATUS_LINE_MAX,
91
91
  NESTED_PREFIX,
92
92
  WORKER_STEP_INDENT,
93
+ SUBORDINATE_LINE_INDENT,
94
+ nestSubordinateCardLines,
93
95
  } from './status-no-truncate.js'
94
96
  import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
95
97
  import { isTelegramSurfaceTool } from './tool-names.js'
@@ -372,6 +374,18 @@ export interface StatusCardOpts {
372
374
  * full recent trail β€” see WORKER_HISTORY_MAX.
373
375
  */
374
376
  historyWindow?: number
377
+ /**
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.
382
+ *
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.
387
+ */
388
+ subordinate?: boolean
375
389
  }
376
390
 
377
391
  /**
@@ -381,7 +395,8 @@ export interface StatusCardOpts {
381
395
  * Pipeline: header β†’ escape+clip every raw step/child β†’ rolling-window each
382
396
  * group with `+N earlier…` β†’ wrap (parent done-styled when children present;
383
397
  * newest-in-progress `β†’` bold else `βœ“` italic; NESTED_PREFIX for children) β†’
384
- * optional `βœ“ N steps` footer β†’ optional result block β†’ fitCardToBudget.
398
+ * optional `βœ“ N steps` footer β†’ optional result block β†’ subordinate nesting
399
+ * (#3820, when `opts.subordinate`) β†’ fitCardToBudget.
385
400
  */
386
401
  export function renderStatusCard(opts: StatusCardOpts): string | null {
387
402
  const { header, final = false, liveSuffix = '', stepCount, result } = opts
@@ -453,7 +468,9 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
453
468
  // collapseSafe (#3666): the agent status card and the single-worker card are
454
469
  // both PINNED, and Telegram's pinned bar shows them collapsed to one line
455
470
  // with the newlines dropped β€” the separator keeps that preview legible.
456
- const joined = stackCardLines(out, { collapseSafe: true })
471
+ const joined = stackCardLines(opts.subordinate === true ? nestSubordinateCardLines(out) : out, {
472
+ collapseSafe: true,
473
+ })
457
474
  if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
458
475
  return fitCardToBudget(opts, headerLines)
459
476
  }
@@ -475,6 +492,15 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
475
492
  const rawSteps = opts.steps.filter((s) => s != null)
476
493
  const rawChildren = (opts.childSteps ?? []).map((s) => s.trim()).filter((s) => s.length > 0)
477
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 })
478
504
 
479
505
  // Fixed footer/result lines (always kept).
480
506
  const footerLines: string[] = []
@@ -483,7 +509,9 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
483
509
  footerLines.push(WORKER_RESULT_RULE)
484
510
  footerLines.push(`${result.emoji} _${escapeMarkdown(truncate(result.text, WORKER_RESULT_MAX))}_`)
485
511
  }
486
- const fixedCost = [...headerLines, ...footerLines].join('\n').length
512
+ const fixedCost =
513
+ [...headerLines, ...footerLines].join('\n').length +
514
+ (headerLines.length + footerLines.length) * nestCost
487
515
 
488
516
  // The active "body" group whose oldest bullets we drop: children when
489
517
  // present (parent collapses to a single "+N" marker), else parent steps.
@@ -507,7 +535,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
507
535
  shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
508
536
  lines.push(...footerLines)
509
537
  // collapseSafe: same pinned surface as renderStatusCard (#3666).
510
- const candidate = stackCardLines(lines, { collapseSafe: true })
538
+ const candidate = stack(lines)
511
539
  if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
512
540
  }
513
541
 
@@ -515,11 +543,12 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
515
543
  // newest text, then escape, then wrap β€” re-checking post-escape because
516
544
  // escaping can expand the string (& β†’ &amp;).
517
545
  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
546
+ const wrapperOverhead =
547
+ (final ? (prefix + '_βœ“ _').length : (prefix + '**β†’ **').length + liveSuffix.length) + nestCost
521
548
  const headerFooterCost =
522
- fixedCost + (fixedCost > 0 ? 1 : 0) + (parentMarker != null ? parentMarker.length + 1 : 0)
549
+ fixedCost +
550
+ (fixedCost > 0 ? 1 : 0) +
551
+ (parentMarker != null ? parentMarker.length + nestCost + 1 : 0)
523
552
  const budget = STATUS_CARD_CHAR_BUDGET - headerFooterCost - wrapperOverhead
524
553
  let raw = rawNewest.slice(0, Math.max(0, budget))
525
554
  let newest = escapeMarkdown(raw)
@@ -533,7 +562,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
533
562
  if (parentMarker != null) lines.push(parentMarker)
534
563
  lines.push(newestLine)
535
564
  lines.push(...footerLines)
536
- return stackCardLines(lines, { collapseSafe: true })
565
+ return stack(lines)
537
566
  }
538
567
 
539
568
  /**
@@ -769,7 +798,7 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
769
798
  const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
770
799
  const toolWord = tools === 1 ? 'tool' : 'tools'
771
800
  return (
772
- `πŸ›  **Workers** Β· _${rows.length} running Β· oldest ${formatFeedElapsed(oldestMs)}` +
801
+ `πŸ›  **WORKERS** Β· _${rows.length} running Β· oldest ${formatFeedElapsed(oldestMs)}` +
773
802
  ` Β· ${tools} ${toolWord}${tokenSegment(tok)}_`
774
803
  )
775
804
  }
@@ -778,13 +807,17 @@ function glanceLine(rows: CombinedWorkerRow[]): string {
778
807
  * Render Nβ‰₯1 live workers into ONE combined feed body (ready Telegram
779
808
  * markdown; callers send verbatim β€” do NOT re-escape). Layout:
780
809
  *
781
- * πŸ›  **Workers** Β· _N running Β· oldest {elapsed} Β· {n} tools Β· {t} tok_
782
- * **1. {desc1}** _Β· {elapsed} Β· {n} tools_
783
- * ~~_βœ“ {earlier step}_~~
784
- * **β†’ {newest step}**
785
- * **2. {desc2}** _Β· {elapsed} Β· {n} tools_
786
- * **β†’ {newest step}**
787
- * _+M more working…_
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…_
817
+ *
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.
788
821
  *
789
822
  * INDENT: every step line carries a leading `WORKER_STEP_INDENT` (a U+2800 run
790
823
  * β€” neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see the
@@ -893,7 +926,17 @@ export function renderCombinedWorkerFeed(
893
926
  // renders it collapsed to one line with the newlines dropped and nothing
894
927
  // substituted. Without the separator the glance line runs straight into
895
928
  // row 1's ordinal and every step glyph mashes into the previous line.
896
- return { body: stackCardLines(out, { collapseSafe: true }), bodyLines: bodyOut.length }
929
+ //
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
+ }
897
940
  }
898
941
 
899
942
  // Cap to maxRows first, then shrink the visible set while EITHER the total
@@ -72,6 +72,25 @@ const ACTIVITY_BODY_LINE_RE = /^[β†’βœ“β†³]/u;
72
72
  * Elapsed is `formatFeedElapsed`: `<N>s` under a minute, else `<M>m<SS>s`.
73
73
  */
74
74
  const LIVENESS_HEADER_L1_RE = /^(?:πŸ€–|πŸ› [️]?|βš™[️]?)\s+\S/u;
75
+
76
+ /**
77
+ * Leading chrome a SUBORDINATE (worker) card carries since #3820: the `└─ `
78
+ * header prefix on line 1 and a `U+2800` indent run on every later line, which
79
+ * together make the worker card readable as a child of the πŸ€– agent card.
80
+ *
81
+ * Every line-shape matcher below must normalise it away first: Telegram
82
+ * delivers both verbatim, and NEITHER is stripped by `String.trim()` β€” U+2800
83
+ * is category So, not whitespace (that property is exactly why it survives
84
+ * Telegram's own left-trim; see WORKER_STEP_INDENT). Without this, a worker
85
+ * card's header stops matching `LIVENESS_HEADER_L1_RE` and its body lines stop
86
+ * matching `ACTIVITY_BODY_LINE_RE`.
87
+ */
88
+ const SUBORDINATE_CARD_PREFIX_RE = /^(?:└─\s*|\u2800+)+/u;
89
+
90
+ /** A card line with its subordinate-card nesting chrome removed, trimmed. */
91
+ export function stripCardNesting(line: string): string {
92
+ return line.replace(SUBORDINATE_CARD_PREFIX_RE, "").trim();
93
+ }
75
94
  const LIVENESS_ELAPSED = String.raw`(?:\d+m)?\d+s`;
76
95
  const LIVENESS_HEADER_L2_RE = new RegExp(
77
96
  `^(?:${LIVENESS_ELAPSED}\\s*Β·\\s*\\d+\\s+tools?` +
@@ -94,7 +113,7 @@ const LIVENESS_HEADER_L2_RE = new RegExp(
94
113
  export function isLivenessCardMessage(m: ObservedMessage): boolean {
95
114
  const lines = m.text
96
115
  .split("\n")
97
- .map((l) => l.trim())
116
+ .map((l) => stripCardNesting(l))
98
117
  .filter((l) => l.length > 0);
99
118
  if (lines.length < 2) return false;
100
119
  if (!LIVENESS_HEADER_L1_RE.test(lines[0])) return false;
@@ -124,7 +143,7 @@ export function isLivenessCardMessage(m: ObservedMessage): boolean {
124
143
  export function isActivityFeedMessage(m: ObservedMessage): boolean {
125
144
  const lines = m.text
126
145
  .split("\n")
127
- .map((l) => l.trim())
146
+ .map((l) => stripCardNesting(l))
128
147
  .filter((l) => l.length > 0);
129
148
  if (lines.length === 0) return false;
130
149
  if (lines.every((l) => ACTIVITY_FEED_LINE_RE.test(l))) return true;
@@ -4,6 +4,7 @@ import {
4
4
  isFrameworkFallbackText,
5
5
  isLivenessCardMessage,
6
6
  isWorkerFeedMessage,
7
+ stripCardNesting,
7
8
  WORKER_FEED_RE,
8
9
  } from "./assertions.js";
9
10
 
@@ -50,6 +51,34 @@ describe("isWorkerFeedMessage", () => {
50
51
  it("exposes the regex for scenarios that assert on the feed directly", () => {
51
52
  expect(WORKER_FEED_RE.test("πŸ›  Worker Β· x")).toBe(true);
52
53
  });
54
+
55
+ it("still matches the #3820 SUBORDINATE worker card as Telegram delivers it", () => {
56
+ // Since #3820 the worker card leads with `└─ ` and indents every later line
57
+ // with a U+2800 run so it reads as a child of the πŸ€– agent card. Telegram
58
+ // strips the bold/italic entities but delivers that chrome verbatim, and
59
+ // `String.trim()` removes neither (U+2800 is category So, not whitespace) β€”
60
+ // so every matcher here has to survive it or recall/reply scenarios start
61
+ // latching onto worker cards as answers.
62
+ const nested = "└─ πŸ›  WORKER Β· crawling changelog\nβ €β €β €55s Β· 9 tools Β· opus 5";
63
+ expect(isWorkerFeedMessage(feed(nested))).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("#3820 subordinate-card nesting is transparent to the matchers", () => {
68
+ it("classifies a nested agent-shaped card as the liveness card, not an answer", () => {
69
+ const nested =
70
+ "└─ πŸ›  WORKER Β· crawl\nβ €β €β €12s Β· 3 tools\nβ €β €β €βœ“ Reading CLAUDE.md\nβ €β €β €β†’ Searching memory";
71
+ expect(isLivenessCardMessage(feed(nested))).toBe(true);
72
+ expect(isActivityFeedMessage(feed(nested))).toBe(true);
73
+ });
74
+
75
+ it("strips the header prefix and the indent, and leaves ordinary prose alone", () => {
76
+ expect(stripCardNesting("└─ πŸ›  WORKER Β· crawl")).toBe("πŸ›  WORKER Β· crawl");
77
+ expect(stripCardNesting("β €β €β €β†’ Searching memory")).toBe("β†’ Searching memory");
78
+ expect(stripCardNesting("on it, pulling the logs now")).toBe(
79
+ "on it, pulling the logs now",
80
+ );
81
+ });
53
82
  });
54
83
 
55
84
  describe("isActivityFeedMessage", () => {
@@ -11,7 +11,11 @@
11
11
 
12
12
  import { describe, expect, it } from "vitest";
13
13
  import { spinUp } from "../harness.js";
14
- import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
14
+ import {
15
+ isActivityFeedMessage,
16
+ isFrameworkFallbackText,
17
+ stripCardNesting,
18
+ } from "../assertions.js";
15
19
  import type { ObservedMessage } from "../driver.js";
16
20
 
17
21
  const AGENT = "test-harness";
@@ -30,7 +34,10 @@ const NARRATED_WORK_PROMPT =
30
34
  function narratedBodyLines(text: string): string[] {
31
35
  return text
32
36
  .split("\n")
33
- .map((l) => l.trim())
37
+ // strip the #3820 subordinate-card nesting (`└─ ` / U+2800 indent) first β€”
38
+ // `trim()` removes neither, and a worker card's header/body lines would
39
+ // otherwise read as model narration
40
+ .map((l) => stripCardNesting(l))
34
41
  .filter((l) => l.length > 0)
35
42
  .map((l) => l.replace(/^[β†’βœ“β†³]+\s*/u, "").trim())
36
43
  .filter(
@@ -17,7 +17,11 @@
17
17
 
18
18
  import { describe, expect, it } from "vitest";
19
19
  import { spinUp } from "../harness.js";
20
- import { isActivityFeedMessage, isFrameworkFallbackText } from "../assertions.js";
20
+ import {
21
+ isActivityFeedMessage,
22
+ isFrameworkFallbackText,
23
+ stripCardNesting,
24
+ } from "../assertions.js";
21
25
  import type { ObservedMessage } from "../driver.js";
22
26
 
23
27
  /**
@@ -30,7 +34,10 @@ import type { ObservedMessage } from "../driver.js";
30
34
  function narratedBodyLines(text: string): string[] {
31
35
  return text
32
36
  .split("\n")
33
- .map((l) => l.trim())
37
+ // strip the #3820 subordinate-card nesting (`└─ ` / U+2800 indent) first β€”
38
+ // `trim()` removes neither, and a worker card's header/body lines would
39
+ // otherwise read as model narration
40
+ .map((l) => stripCardNesting(l))
34
41
  .filter((l) => l.length > 0)
35
42
  // strip glyphs so a `β†’ Working…` placeholder compares as `Working…`
36
43
  .map((l) => l.replace(/^[β†’βœ“β†³]+\s*/u, "").trim())
@@ -55,7 +55,11 @@ import {
55
55
  truncate,
56
56
  COLLAPSE_SAFE_SEPARATOR,
57
57
  } from './card-format.js'
58
- import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
+ import {
59
+ WORKER_HISTORY_MAX,
60
+ SUBORDINATE_HEADER_PREFIX,
61
+ SUBORDINATE_LINE_INDENT,
62
+ } from './status-no-truncate.js'
59
63
  import {
60
64
  renderStatusCard,
61
65
  formatStepSuffix,
@@ -164,22 +168,30 @@ export function repeatCountOf(line: string): number {
164
168
 
165
169
  /**
166
170
  * Thin adapter over the unified `renderStatusCard` primitive (emoji πŸ› , label
167
- * 'Worker'): builds the header, passes raw narrative steps (the primitive runs
168
- * stripMarkdown β†’ collapse ws β†’ clip β†’ escape per line), and on finish passes
169
- * the cleaned result paragraph as the `result` block.
171
+ * 'WORKER', `subordinate: true`): builds the header, passes raw narrative steps
172
+ * (the primitive runs stripMarkdown β†’ collapse ws β†’ clip β†’ escape per line),
173
+ * and on finish passes the cleaned result paragraph as the `result` block.
170
174
  *
171
175
  * Layout (running):
172
- * πŸ›  <b>Worker</b> Β· <i>{description}</i>
173
- * <i>{elapsed} Β· {n} tools</i>
174
- * <s><i>βœ“ {earlier step}</i></s>
175
- * <b>β†’ {newest step}</b>
176
+ * └─ πŸ›  **WORKER** Β· _{description}_
177
+ * _{elapsed} Β· {n} tools_
178
+ * ~~_βœ“ {earlier step}_~~
179
+ * **β†’ {newest step}**
176
180
  *
177
181
  * Layout (finished): the feed renders all-done, then a rule + cleaned result:
178
- * πŸ›  <b>Worker</b> Β· <i>{description}</i>
179
- * <i>done Β· {n} tools Β· {elapsed}</i>
180
- * <s><i>βœ“ {step}</i></s>
181
- * ─────
182
- * βœ… <i>{cleaned result paragraph}</i>
182
+ * └─ πŸ›  **WORKER** Β· _{description}_
183
+ * _done Β· {n} tools Β· {elapsed}_
184
+ * ~~_βœ“ {step}_~~
185
+ * ─────
186
+ * βœ… _{cleaned result paragraph}_
187
+ *
188
+ * SUBORDINATION (#3820): the `└─ ` prefix, the whole-block indent, and the
189
+ * caps `WORKER` label exist so this card cannot be mistaken for the πŸ€– agent
190
+ * card at a glance on a phone. Before #3820 the two cards differed only by
191
+ * their emoji and one capitalised word while sharing the identical two-line
192
+ * header, stat row, and step trail. Do NOT "tidy" any of the three away
193
+ * individually: each is a separate one-glance cue, and the pair is frequently
194
+ * live in the same chat at the same time with overlapping step text.
183
195
  */
184
196
  export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): string {
185
197
  const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
@@ -201,7 +213,9 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
201
213
 
202
214
  const header: Parameters<typeof renderStatusCard>[0]['header'] = {
203
215
  emoji: 'πŸ› ',
204
- label: 'Worker',
216
+ // Caps (#3820, issue option 4): `WORKER` against the agent card's `Agent`
217
+ // is a high-contrast type label, not a same-shaped word one letter apart.
218
+ label: 'WORKER',
205
219
  description: desc,
206
220
  elapsedMs: v.elapsedMs,
207
221
  toolCount: v.toolCount,
@@ -237,10 +251,12 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
237
251
  // Lone-worker card: window to the w=1 point of Ken's curve (6) so it shows
238
252
  // the full recent trail, not the 5-line agent-card default (#3349).
239
253
  historyWindow: workerHistoryDepth(1),
254
+ // Structurally subordinate to the πŸ€– agent card (#3820).
255
+ subordinate: true,
240
256
  })
241
257
  if (card == null) {
242
258
  // Unreachable (header always present) β€” defensive.
243
- return `πŸ›  **Worker** Β· _starting…_`
259
+ return `${SUBORDINATE_HEADER_PREFIX}πŸ›  **WORKER** Β· _starting…_`
244
260
  }
245
261
  if (!finished && steps.length === 0) {
246
262
  // Header-only running render β†’ append the starting placeholder with a GFM
@@ -249,7 +265,11 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
249
265
  // The collapse separator is carried here too (#3666) β€” this card is pinned,
250
266
  // and a hand-rolled seam would be the one boundary that still mashed in
251
267
  // Telegram's pinned bar.
252
- return `${card}${COLLAPSE_SAFE_SEPARATOR} \n_starting…_`
268
+ // The placeholder is a card BODY line, so it carries the subordinate indent
269
+ // like every other non-header line (#3820) β€” otherwise the one render state
270
+ // a user sees first (a just-dispatched worker) would be the one that isn't
271
+ // visibly nested.
272
+ return `${card}${COLLAPSE_SAFE_SEPARATOR} \n${SUBORDINATE_LINE_INDENT}_starting…_`
253
273
  }
254
274
  return card
255
275
  }
@@ -598,7 +618,8 @@ const COOLDOWN_JITTER_MS = 500
598
618
  * tick, so it adds no edit churn / pin storm. Plain voice, no em dash.
599
619
  */
600
620
  const WORKER_CARD_SUPERSEDED_BODY =
601
- 'πŸ›  **Worker** Β· _continued_\n\n_Live progress moved to a fresh card to stay pinned._'
621
+ `${SUBORDINATE_HEADER_PREFIX}πŸ›  **WORKER** Β· _continued_\n\n` +
622
+ `${SUBORDINATE_LINE_INDENT}_Live progress moved to a fresh card to stay pinned._`
602
623
 
603
624
  function extractRetryAfterSecs(err: unknown): number | null {
604
625
  if (err == null || typeof err !== 'object') return null