switchroom 0.19.30 → 0.19.32

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 (51) hide show
  1. package/dist/cli/switchroom.js +1121 -584
  2. package/dist/host-control/main.js +151 -73
  3. package/package.json +1 -1
  4. package/profiles/_base/cron-session.sh.hbs +5 -1
  5. package/profiles/_base/start.sh.hbs +15 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +3 -0
  7. package/telegram-plugin/dist/gateway/gateway.js +1660 -677
  8. package/telegram-plugin/dist/server.js +3 -0
  9. package/telegram-plugin/edit-flood-fuse.ts +70 -20
  10. package/telegram-plugin/gateway/boot-beacon.ts +364 -0
  11. package/telegram-plugin/gateway/boot-sweep-gate.ts +20 -15
  12. package/telegram-plugin/gateway/gateway.ts +87 -88
  13. package/telegram-plugin/gateway/inbound-spool.ts +39 -0
  14. package/telegram-plugin/gateway/narrative-lane.ts +12 -0
  15. package/telegram-plugin/gateway/obligation-store.ts +28 -0
  16. package/telegram-plugin/gateway/stale-pin-sweep-store.ts +221 -0
  17. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +211 -0
  18. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +804 -0
  19. package/telegram-plugin/gateway/stale-pin-sweep.ts +1146 -0
  20. package/telegram-plugin/gateway/status-pin-retarget.ts +15 -2
  21. package/telegram-plugin/gateway/status-pin-store.ts +33 -11
  22. package/telegram-plugin/gateway/stream-render.ts +578 -321
  23. package/telegram-plugin/registry/turns-schema.ts +21 -1
  24. package/telegram-plugin/retry-api-call.ts +46 -21
  25. package/telegram-plugin/session-tail.ts +13 -0
  26. package/telegram-plugin/shared/bot-runtime.ts +61 -17
  27. package/telegram-plugin/shared/gw-trace-gate.ts +18 -2
  28. package/telegram-plugin/tests/activity-card-wiring.test.ts +7 -7
  29. package/telegram-plugin/tests/activity-drain-fuse-drop-not-failure.test.ts +324 -0
  30. package/telegram-plugin/tests/agent-card-result-footer.test.ts +193 -0
  31. package/telegram-plugin/tests/boot-beacon.test.ts +462 -0
  32. package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +6 -6
  33. package/telegram-plugin/tests/boot-sweep-gate.test.ts +42 -31
  34. package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +2 -0
  35. package/telegram-plugin/tests/inbound-spool-progress.test.ts +2 -0
  36. package/telegram-plugin/tests/inbound-spool.test.ts +134 -5
  37. package/telegram-plugin/tests/narrative-lane-golden.test.ts +28 -0
  38. package/telegram-plugin/tests/obligation-determinism.test.ts +2 -0
  39. package/telegram-plugin/tests/obligation-store.test.ts +67 -1
  40. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  41. package/telegram-plugin/tests/status-pin-store.test.ts +26 -5
  42. package/telegram-plugin/tests/stream-render-golden.test.ts +20 -5
  43. package/telegram-plugin/tests/tg-post-logger-error-shape.test.ts +161 -0
  44. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +196 -0
  45. package/telegram-plugin/tests/turn-mint-harness.ts +155 -0
  46. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +124 -0
  47. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +30 -0
  48. package/telegram-plugin/tool-activity-summary.ts +104 -38
  49. package/telegram-plugin/worker-activity-feed.ts +33 -16
  50. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +0 -251
  51. package/telegram-plugin/gateway/dm-pin-sweep.ts +0 -178
@@ -0,0 +1,124 @@
1
+ /**
2
+ * #3927 FIX B — a superseded turn must never be left holding an orphaned card.
3
+ *
4
+ * The turn-supersession teardown in `stream-render.ts` tore down the prior
5
+ * turn's `answerStream`, its orphaned-reply fuse and its `narrativeGate`, but
6
+ * NEVER called `clearActivitySummary(prior)`. So a clobbered turn's activity
7
+ * card was never finalized, never unpinned (`fg:<statusKey>` stayed claimed
8
+ * forever), froze on its last landed edit, and left NO `turn-lifecycle clear`
9
+ * line to explain it. Proof from the field: carrie's turn
10
+ * `-1004223464247:_#1078` has a `turn-lifecycle set reason=enqueue` at
11
+ * 2026-07-28T18:13:37.554Z and no `clear` anywhere in the log; its stale pinned
12
+ * card was still on screen in the operator's screenshot.
13
+ *
14
+ * ── Which path still supersedes, after FIX A ──────────────────────────────
15
+ * FIX A parks EVERY mid-turn enqueue, synthetic ones included — that is not a
16
+ * policy choice, it is what the claude CLI does (carrie's `obligation_represent`
17
+ * enqueue at 18:19:07.032Z was terminated by a `remove` at 18:19:59.794Z, i.e.
18
+ * folded into the running turn as a `queued_command` attachment, never by a
19
+ * `dequeue`). So an enqueue no longer preempts, and the case below asserts that
20
+ * explicitly.
21
+ *
22
+ * Supersession is still REACHABLE, though: a turn whose `turn_end` the gateway
23
+ * never observed (bridge death, transcript gap, unclean restart) leaves a
24
+ * live-looking atom in the slot, and the next genuine dequeue-driven turn start
25
+ * mints straight on top of it. That is the exact orphan carrie hit, and it is
26
+ * what FIX B finalizes.
27
+ */
28
+ import { describe, it, expect, beforeEach } from 'vitest'
29
+ import {
30
+ handleSessionEvent,
31
+ __resetParkedTurnStartsForTest,
32
+ __parkedTurnStartCountForTest,
33
+ } from '../gateway/stream-render.js'
34
+ import { enqueue, makeHarness } from './turn-mint-harness.js'
35
+
36
+ /** A cron / handback / wake enqueue: no `message_id`, so `deriveTurnId` falls
37
+ * back to `…#synthetic-<startedAt>`. */
38
+ function syntheticEnqueue(chatId: string, text: string) {
39
+ return {
40
+ kind: 'enqueue' as const,
41
+ chatId,
42
+ messageId: null,
43
+ threadId: null,
44
+ rawContent: `<channel source="switchroom-telegram" source="cron">${text}</channel>`,
45
+ }
46
+ }
47
+
48
+ beforeEach(() => {
49
+ __resetParkedTurnStartsForTest()
50
+ })
51
+
52
+ describe('#3927 FIX B — superseding a live turn finalizes its card', () => {
53
+ it('a dequeue-driven mint on top of a never-ended turn calls clearActivitySummary ' +
54
+ 'for the PRIOR turn, and does so BEFORE the successor opens its card', () => {
55
+ const h = makeHarness()
56
+
57
+ // Turn A starts and opens a card with real work on it.
58
+ handleSessionEvent(h.deps, enqueue('1078'))
59
+ const turnA = h.current()!
60
+ handleSessionEvent(h.deps, { kind: 'tool_label', toolName: 'Read', label: 'Reading the log' })
61
+ expect(turnA.labeledToolCount).toBe(1)
62
+ expect(h.finalized).toHaveLength(0)
63
+
64
+ // A's `turn_end` is NEVER observed — `endedAt` stays null and the atom
65
+ // stays in the slot (exactly carrie's `#1078`).
66
+ expect(turnA.endedAt).toBeNull()
67
+
68
+ // A later message is queued and the CLI drains it into a new turn.
69
+ handleSessionEvent(h.deps, enqueue('1086'))
70
+ expect(__parkedTurnStartCountForTest()).toBe(1)
71
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
72
+
73
+ const turnB = h.current()!
74
+ expect(turnB).not.toBe(turnA)
75
+
76
+ // FIX B: the orphan was finalized — pre-fix there was NO call at all.
77
+ expect(h.finalized).toHaveLength(1)
78
+ expect(h.finalized[0]).toBe(turnA)
79
+
80
+ // …and it happened BEFORE the successor's card opened, so the old card is
81
+ // finalized/unpinned rather than left frozen above a fresh one.
82
+ expect(h.seq).toEqual([
83
+ `open:${turnA.turnId}`,
84
+ `finalize:${turnA.turnId}`,
85
+ `open:${turnB.turnId}`,
86
+ ])
87
+ })
88
+
89
+ it('a turn that ENDED normally is not re-finalized when its successor mints', () => {
90
+ const h = makeHarness()
91
+ handleSessionEvent(h.deps, enqueue('1090'))
92
+ const turnA = h.current()!
93
+ // turn-end.ts stamps `endedAt` and runs its own clearActivitySummary.
94
+ turnA.endedAt = Date.now()
95
+
96
+ handleSessionEvent(h.deps, enqueue('1091'))
97
+ // Idle by `endedAt`, so this mints straight away — and must NOT double-
98
+ // finalize A's already-closed card.
99
+ expect(h.current()).not.toBe(turnA)
100
+ expect(h.finalized).toHaveLength(0)
101
+ })
102
+
103
+ it('FIX A is uniform across sources: a SYNTHETIC (cron/handback) enqueue parks ' +
104
+ 'behind a live turn instead of preempting it', () => {
105
+ const h = makeHarness()
106
+ handleSessionEvent(h.deps, enqueue('1100'))
107
+ const turnA = h.current()!
108
+
109
+ handleSessionEvent(h.deps, syntheticEnqueue('1001', 'time for the daily digest'))
110
+
111
+ // No preemption: no new card, no slot steal, no orphaned finalize.
112
+ expect(h.current()).toBe(turnA)
113
+ expect(h.cardsOpened).toHaveLength(1)
114
+ expect(h.finalized).toHaveLength(0)
115
+ expect(__parkedTurnStartCountForTest()).toBe(1)
116
+
117
+ // It mints on the CLI's own turn-start signal, like every other source.
118
+ turnA.endedAt = Date.now()
119
+ handleSessionEvent(h.deps, { kind: 'dequeue' })
120
+ expect(h.current()).not.toBe(turnA)
121
+ expect(h.current()!.turnId).toContain('#synthetic-')
122
+ expect(h.cardsOpened).toHaveLength(2)
123
+ })
124
+ })
@@ -217,6 +217,36 @@ function makePersistingPinHarness(path: string) {
217
217
  describe('worker-feed pin persistence — durable status-pins.json survives steady-state edits (F1)', () => {
218
218
  const PATH = '/state/agent/telegram/status-pins.json'
219
219
 
220
+ it('writes an UNAMBIGUOUS (chat, thread) pin key — no trailing-space topic slot', async () => {
221
+ // The key is persisted verbatim into status-pins.json. It used to be
222
+ // `${chatId} ${threadId ?? ''}`, so a topic-less group wrote
223
+ // `"pinKey": "wk:group:-100123 "` — trailing whitespace that survives a
224
+ // JSON round-trip and is invisible in every log and grep.
225
+ const bot = makeFakeBot()
226
+ const pin = makePersistingPinHarness(PATH)
227
+ let clock = 0
228
+ const feed = createWorkerActivityFeed({
229
+ bot,
230
+ now: () => clock,
231
+ firstPaintMinMs: 0,
232
+ minEditIntervalMs: 0,
233
+ reconcilePin: pin.reconcilePinFn,
234
+ })
235
+
236
+ clock = 1000
237
+ await feed.update('w-topicless', '-100123', view({ elapsedMs: 1000, toolCount: 1 }))
238
+ await flush()
239
+ clock = 2000
240
+ await feed.update('w-topic', '-100123', view({ elapsedMs: 1000, toolCount: 1 }), 7)
241
+ await flush()
242
+
243
+ const keys = pin.rows().map((r) => r.pinKey).sort()
244
+ expect(keys).toEqual(['wk:group:-100123:-', 'wk:group:-100123:7'])
245
+ // Same chat, different topic ⇒ genuinely different rows, and no key can be
246
+ // mistaken for another by an operator or a tool reconciling by chat.
247
+ for (const k of keys) expect(k).toBe(k.trim())
248
+ })
249
+
220
250
  it('preserves the wk:group row across many steady-state edits (noop-clear must NOT delete it)', async () => {
221
251
  const bot = makeFakeBot()
222
252
  const pin = makePersistingPinHarness(PATH)
@@ -91,7 +91,8 @@ import {
91
91
  NESTED_PREFIX,
92
92
  WORKER_STEP_INDENT,
93
93
  } from './status-no-truncate.js'
94
- import { escapeMarkdown, truncate } from './card-format.js'
94
+ import { cleanWorkerResultParagraph, escapeMarkdown, truncate } from './card-format.js'
95
+ import { redact } from './secret-detect/redact.js'
95
96
  import { isTelegramSurfaceTool } from './tool-names.js'
96
97
  // The card layout core. Header composition (`metricsRun` /
97
98
  // `renderActivityHeader`), the per-line escape pipeline (`escapeStepLine`), the
@@ -158,6 +159,15 @@ export interface SessionActivityHeader {
158
159
  * line via `tokenSegment`. Omitted (0/undefined) → no token segment, same
159
160
  * clean-omit behavior as the worker feed. */
160
161
  totalTokens?: number
162
+ /**
163
+ * RAW final-summary text for the terminal `✅ <summary>` footer — the agent
164
+ * card's analogue of the worker card's `latestSummary` (the gateway passes
165
+ * the turn's delivered answer, `turn.lastReplyText`). Rendered ONLY on a
166
+ * final render, through the SHARED `deriveCardResult` the worker card uses,
167
+ * so both surfaces clean/cap/emoji it identically. Absent or empty → no
168
+ * footer block (the worker card's own fallback), never a fabricated line.
169
+ */
170
+ resultText?: string
161
171
  }
162
172
 
163
173
  /**
@@ -456,32 +466,71 @@ const WORKER_RESULT_RULE = '─────'
456
466
  const WORKER_RESULT_MAX = 320
457
467
 
458
468
  /**
459
- * Render the accumulated feed as ready Telegram HTML one action per line,
460
- * newest last. The current (newest) step is bold with a `→`; finished steps
461
- * are italic with a `✓`. Capped to the last STATUS_ROLLING_LINES with a dim
462
- * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
463
- * Callers send the result verbatim do NOT re-escape or re-wrap it.
469
+ * The ONE derivation of a card's terminal `✅/⚠️ <summary>` footer block, shared
470
+ * by the 🤖 agent card and the 🛠 worker card (#3844 unified the card BODY; the
471
+ * footer stayed forked until this landed the agent card silently had no
472
+ * result block at all, so it ended on `✓ N steps` while the worker card ended
473
+ * on the green tick + summary sentence).
464
474
  *
465
- * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent').
475
+ * Contract (the worker card's long-standing behaviour, now the spec for both):
476
+ * - `running` → no block. The card is not finished.
477
+ * - `incomplete` → NEVER a block, whatever `summary` carries. Truthful-no-
478
+ * result invariant: a reaped/abandoned unit produced no
479
+ * result, so it must not render a `⚠️`-prefixed paragraph
480
+ * out of stray summary text. Enforced HERE (deterministic
481
+ * mechanism) rather than left to caller discipline.
482
+ * - `done` → `✅` + the cleaned paragraph.
483
+ * - `failed` → `⚠️` + the cleaned paragraph.
484
+ * - empty/absent summary, or one that cleans to nothing → no block. Omitting
485
+ * is the fallback; the card never fabricates a sentence.
466
486
  *
467
- * `stepCount` (optional): when `final=true` and `stepCount > 0`, appends a
468
- * `✓ N steps` footer line.
487
+ * `summary` is RAW model-authored text (markdown, multi-line). It is scrubbed
488
+ * with `redact()` and cleaned with `cleanWorkerResultParagraph`;
489
+ * `renderStatusCard` does the final truncate + escape when it emits the block.
469
490
  *
470
- * `header` (optional): when provided, the two-line activity header carries
471
- * elapsed + tool count.
491
+ * The `redact()` is load-bearing, not belt-and-braces: status cards are sent
492
+ * via `sendRichMessage` and BYPASS the outbound redact chokepoint
493
+ * (`normalizeOutboundBody` → `redact`, outbound-send-path.ts:192) that scrubs
494
+ * every ordinary reply — the same gap `emitGatewayOperatorEvent` closes in
495
+ * gateway.ts. The agent card's summary is the turn's delivered answer text
496
+ * taken PRE-redaction, so scrubbing here is what keeps a token smuggled into a
497
+ * summary from reaching Telegram verbatim. It runs BEFORE markdown stripping /
498
+ * escaping, which is the order the outbound pipeline requires (redacting
499
+ * already-escaped text lets url-query-param secrets slip past url-redact).
472
500
  */
473
- export function renderActivityFeed(
501
+ export function deriveCardResult(
502
+ state: CardState,
503
+ summary: string | undefined,
504
+ ): { emoji: string; text: string } | undefined {
505
+ if (state !== 'done' && state !== 'failed') return undefined
506
+ const text = cleanWorkerResultParagraph(redact(summary ?? ''))
507
+ if (text.length === 0) return undefined
508
+ return { emoji: state === 'done' ? '✅' : '⚠️', text }
509
+ }
510
+
511
+ /** Leading emoji for the main-session (🤖 Agent) card. */
512
+ const AGENT_CARD_EMOJI = '🤖'
513
+
514
+ /**
515
+ * The single 🤖-agent-card configuration of `renderStatusCard`. Both public
516
+ * agent-card entrypoints (`renderActivityFeed`, `renderActivityFeedWithNested`)
517
+ * funnel through this so the header mapping, the empty-guard, and the terminal
518
+ * result footer cannot drift between the flat and nested renders — they used
519
+ * to be two byte-copies of the same object literal.
520
+ */
521
+ function renderAgentCard(
474
522
  lines: string[],
475
- final = false,
476
- liveSuffix = "",
477
- stepCount?: number,
478
- header?: SessionActivityHeader,
523
+ children: string[],
524
+ final: boolean,
525
+ liveSuffix: string,
526
+ stepCount: number | undefined,
527
+ header: SessionActivityHeader | undefined,
479
528
  ): string | null {
480
- if (lines.length === 0 && header == null) return null;
529
+ if (lines.length === 0 && children.length === 0 && header == null) return null
481
530
  return renderStatusCard({
482
531
  header: header != null
483
532
  ? {
484
- emoji: '🤖',
533
+ emoji: AGENT_CARD_EMOJI,
485
534
  label: header.label,
486
535
  elapsedMs: header.elapsedMs,
487
536
  toolCount: header.toolCount,
@@ -491,12 +540,46 @@ export function renderActivityFeed(
491
540
  }
492
541
  : undefined,
493
542
  steps: lines,
543
+ ...(children.length > 0 ? { childSteps: children } : {}),
494
544
  final,
495
545
  liveSuffix,
496
546
  stepCount,
547
+ // Same footer derivation the 🛠 worker card uses — one code path, so the
548
+ // two surfaces cannot drift apart again. Only a FINAL render can carry a
549
+ // result; a live render passes 'running' through and gets undefined.
550
+ result: final && header != null
551
+ ? deriveCardResult(header.state, header.resultText)
552
+ : undefined,
497
553
  })
498
554
  }
499
555
 
556
+ /**
557
+ * Render the accumulated feed as ready Telegram HTML — one action per line,
558
+ * newest last. The current (newest) step is bold with a `→`; finished steps
559
+ * are italic with a `✓`. Capped to the last STATUS_ROLLING_LINES with a dim
560
+ * `✓ +N earlier…` header when the turn ran longer. Returns null when empty.
561
+ * Callers send the result verbatim — do NOT re-escape or re-wrap it.
562
+ *
563
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent') via the shared
564
+ * `renderAgentCard` configuration.
565
+ *
566
+ * `stepCount` (optional): when `final=true` and `stepCount > 0`, appends a
567
+ * `✓ N steps` footer line.
568
+ *
569
+ * `header` (optional): when provided, the two-line activity header carries
570
+ * elapsed + tool count — and, on a final render, `header.resultText` supplies
571
+ * the terminal `✅ <summary>` footer (identical derivation to the worker card).
572
+ */
573
+ export function renderActivityFeed(
574
+ lines: string[],
575
+ final = false,
576
+ liveSuffix = "",
577
+ stepCount?: number,
578
+ header?: SessionActivityHeader,
579
+ ): string | null {
580
+ return renderAgentCard(lines, [], final, liveSuffix, stepCount, header)
581
+ }
582
+
500
583
  // ─── Foreground sub-agent nesting (Model A) ─────────────────────────────────
501
584
  //
502
585
  // A foreground sub-agent (Task/Agent with no `run_in_background`) runs INSIDE
@@ -519,7 +602,8 @@ export const NESTED_MAX_LINES = 4;
519
602
  * a `↳ +N earlier…` header when it overflows. Returns ready Telegram HTML
520
603
  * (callers must NOT re-escape) or null when there is nothing to show.
521
604
  *
522
- * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent', childSteps).
605
+ * Thin adapter over `renderStatusCard` (emoji 🤖, label 'Agent', childSteps) via
606
+ * the shared `renderAgentCard` configuration.
523
607
  */
524
608
  export function renderActivityFeedWithNested(
525
609
  lines: string[],
@@ -530,25 +614,7 @@ export function renderActivityFeedWithNested(
530
614
  header?: SessionActivityHeader,
531
615
  ): string | null {
532
616
  const children = childLines.map((s) => s.trim()).filter((s) => s.length > 0);
533
- if (children.length === 0) return renderActivityFeed(lines, final, liveSuffix, stepCount, header);
534
- return renderStatusCard({
535
- header: header != null
536
- ? {
537
- emoji: '🤖',
538
- label: header.label,
539
- elapsedMs: header.elapsedMs,
540
- toolCount: header.toolCount,
541
- state: header.state,
542
- model: header.model,
543
- totalTokens: header.totalTokens,
544
- }
545
- : undefined,
546
- steps: lines,
547
- childSteps: children,
548
- final,
549
- liveSuffix,
550
- stepCount,
551
- })
617
+ return renderAgentCard(lines, children, final, liveSuffix, stepCount, header)
552
618
  }
553
619
 
554
620
  // ─── Combined multi-worker feed (coalesced one-message-per-chat) ────────────
@@ -50,13 +50,13 @@
50
50
  */
51
51
 
52
52
  import {
53
- cleanWorkerResultParagraph,
54
53
  stripMarkdown,
55
54
  truncate,
56
55
  } from './card-format.js'
57
56
  import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
58
57
  import { renderCardTitleLine } from './card-layout.js'
59
58
  import {
59
+ deriveCardResult,
60
60
  renderStatusCard,
61
61
  formatStepSuffix,
62
62
  renderCombinedWorkerFeed,
@@ -274,17 +274,12 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
274
274
  // Terminal: latestSummary carries the worker's final result text (gateway
275
275
  // onFinish), distinct from the running narrative steps. Pass it as `result`.
276
276
  //
277
- // Truthful-no-result invariant (deterministic control, not caller-discipline):
278
- // an `incomplete` worker produced NO result, so it must NEVER render a result
279
- // block regardless of whatever `latestSummary` happens to carry. The current
280
- // call site (terminateWorker) always sets latestSummary:'' for incomplete, but
281
- // enforce the invariant HERE so any future/direct caller can't fabricate a
282
- // `⚠️`-prefixed result paragraph out of stray summary text.
283
- let result: { emoji: string; text: string } | undefined
284
- if (finished && v.state !== 'incomplete') {
285
- const text = cleanWorkerResultParagraph(v.latestSummary)
286
- if (text.length > 0) result = { emoji: v.state === 'done' ? '✅' : '⚠️', text }
287
- }
277
+ // The whole derivation — state gating, the truthful-no-result invariant for
278
+ // `incomplete`, ✅/⚠️ selection, and omission on an empty summary lives ONCE
279
+ // in `deriveCardResult` and is SHARED with the 🤖 agent card. It used to be
280
+ // inline here, which is precisely why the agent card had no result footer at
281
+ // all: the logic was unreachable from the other surface.
282
+ const result = deriveCardResult(v.state, v.latestSummary)
288
283
 
289
284
  const card = renderStatusCard({
290
285
  header,
@@ -581,7 +576,7 @@ interface WorkerRow {
581
576
  * a 2+ worker group renders the combined `renderCombinedWorkerFeed` body.
582
577
  */
583
578
  interface FeedGroup {
584
- /** Stable key `${chatId} ${threadId ?? ''}`. */
579
+ /** Stable `(chat, thread)` key see `feedKeyOf`: `<chatId>:<threadId|->`. */
585
580
  feedKey: string
586
581
  chatId: string
587
582
  threadId?: number
@@ -735,7 +730,7 @@ export interface WorkerActivityFeed {
735
730
  * re-post). Lets the gateway pin the EXISTING `🛠 Worker` message. Note:
736
731
  * siblings sharing the chat/thread return the SAME id (one message). */
737
732
  messageIdOf(agentId: string): number | null
738
- /** True while the feed group `feedKey` (`${chatId} ${threadId ?? ''}`) still
733
+ /** True while the feed group `feedKey` (see `feedKeyOf` `<chatId>:<threadId|->`) still
739
734
  * tracks live work — used by the gateway's `wk:group:` pin reaper to exempt
740
735
  * a live group's pin from the stale-TTL sweep (#3207). */
741
736
  hasRunningInFeed(feedKey: string): boolean
@@ -822,7 +817,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
822
817
  })
823
818
  const clearIntervalFn = opts.clearInterval ?? ((handle: unknown) => clearInterval(handle as ReturnType<typeof setInterval>))
824
819
 
825
- /** Feed groups keyed by `${chatId} ${threadId ?? ''}`. */
820
+ /** Feed groups keyed by `feedKeyOf(chatId, threadId)` `<chatId>:<threadId|->`. */
826
821
  const groups = new Map<string, FeedGroup>()
827
822
  /** Reverse index agentId → feedKey, so the agentId-keyed public API resolves
828
823
  * its group in O(1). Cleared when a worker's row is removed. */
@@ -849,8 +844,30 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
849
844
  }
850
845
  let heartbeatTimer: unknown = null
851
846
 
847
+ /**
848
+ * The `(chat, thread)` identity of a feed group — and, prefixed with
849
+ * `wk:group:`, the pin key that gets PERSISTED into status-pins.json.
850
+ *
851
+ * The separator and the topic-less sentinel are both load-bearing. This used
852
+ * to be `` `${chatId} ${threadId ?? ''}` ``, which for a topic-less group
853
+ * rendered as `-1004223464247 ` — a key ending in a bare space. On disk that
854
+ * became `"pinKey": "wk:group:-1004223464247 "`: trailing whitespace that
855
+ * survives JSON round-trips, is invisible in every log line and grep, and
856
+ * cannot be distinguished by eye from the same chat WITH a topic. Any operator
857
+ * or tool reconciling rows by chat would silently treat the two as one.
858
+ *
859
+ * `<chatId>:<threadId|->` is unambiguous in both directions: the topic-less
860
+ * case is spelled explicitly (`-`), and a topic key can never be confused with
861
+ * a topic-less one. Note chat ids are negative, so the `-` sentinel and the
862
+ * sign of the id never occupy the same position.
863
+ *
864
+ * The value is opaque to every consumer (it round-trips through the pin key
865
+ * and back into `hasRunningInFeed`), so the format may change; a key written
866
+ * by an older build is work-scoped and is unpinned unconditionally by the next
867
+ * boot's cleanup, so there is nothing to migrate.
868
+ */
852
869
  function feedKeyOf(chatId: string, threadId?: number): string {
853
- return `${chatId} ${threadId ?? ''}`
870
+ return `${chatId}:${threadId ?? '-'}`
854
871
  }
855
872
  function groupOfAgent(agentId: string): FeedGroup | undefined {
856
873
  const key = agentIndex.get(agentId)