switchroom 0.18.24 → 0.18.25

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 (42) hide show
  1. package/dist/cli/switchroom.js +59 -11
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/telegram-plugin/dist/bridge/bridge.js +26 -0
  5. package/telegram-plugin/dist/gateway/gateway.js +1489 -829
  6. package/telegram-plugin/dist/server.js +26 -0
  7. package/telegram-plugin/gateway/callback-query-handlers.ts +7 -0
  8. package/telegram-plugin/gateway/gateway.ts +314 -3
  9. package/telegram-plugin/gateway/model-command.ts +188 -56
  10. package/telegram-plugin/gateway/redelivery-decision.ts +139 -0
  11. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +42 -1
  12. package/telegram-plugin/history.ts +118 -0
  13. package/telegram-plugin/registry/turns-schema.ts +89 -1
  14. package/telegram-plugin/session-tail.ts +185 -0
  15. package/telegram-plugin/subagent-watcher.ts +45 -0
  16. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  17. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  18. package/telegram-plugin/tests/history.test.ts +91 -0
  19. package/telegram-plugin/tests/model-command.test.ts +189 -12
  20. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  21. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  22. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  23. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  24. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  25. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  26. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  27. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  28. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  29. package/telegram-plugin/tool-activity-summary.ts +54 -3
  30. package/telegram-plugin/worker-activity-feed.ts +104 -0
  31. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  32. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  33. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  34. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  35. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  36. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  37. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  38. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  39. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  40. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  41. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  42. package/vendor/hindsight-memory/tests/test_hooks.py +8 -2
@@ -0,0 +1,306 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createWorkerActivityFeed,
4
+ type WorkerActivityView,
5
+ type BotApiForWorkerFeed,
6
+ } from '../worker-activity-feed.js'
7
+ import { reconcilePin, type PinBotApi } from '../status-pin-driver.js'
8
+ import type { PinState, DesiredPin } from '../status-pin.js'
9
+
10
+ /**
11
+ * Outcome tests for the invisible-worker-cards fix (2026-07-15).
12
+ *
13
+ * Root cause: the worker feed reuses ONE shared group message per chat for the
14
+ * whole time workers overlap, but `syncPin` was called only at lifecycle edges
15
+ * (first-paint / terminal / GC) — NEVER on the steady-state edit path. So once
16
+ * the pin was lost out-of-band (its in-memory claim dropped), the reused message
17
+ * kept being edited live but was never re-pinned → scroll-buried / invisible.
18
+ *
19
+ * These tests wire the feed's `reconcilePin` hook into the REAL status-pin
20
+ * driver + a claim map exactly as the gateway does (`wk:group:<feedKey>` key,
21
+ * `void reconcileStatusPin`), and assert the PIN-CALL OUTCOMES:
22
+ * 1. a steady-state edit re-pins when the claim was lost — and does NOT re-pin
23
+ * (no storm) when the claim is already correct.
24
+ * 2. routing an unpin through the reconciler CLEARS the claim, so a later edit
25
+ * re-pins.
26
+ * 3. the group-message lifetime cap ROTATES to a fresh message id and re-
27
+ * establishes the pin on the new message.
28
+ */
29
+
30
+ function view(partial: Partial<WorkerActivityView> = {}): WorkerActivityView {
31
+ return {
32
+ description: 'background task',
33
+ lastTool: { name: 'Bash', sanitisedArg: 'grep -r x' },
34
+ toolCount: 1,
35
+ latestSummary: 'working',
36
+ elapsedMs: 10_000,
37
+ state: 'running',
38
+ ...partial,
39
+ }
40
+ }
41
+
42
+ interface FakeBot extends BotApiForWorkerFeed {
43
+ sent: Array<{ chatId: string; messageId: number; text: string }>
44
+ edits: Array<{ messageId: number; text: string }>
45
+ }
46
+
47
+ function makeFakeBot(): FakeBot {
48
+ let nextId = 1000
49
+ const fb: FakeBot = {
50
+ sent: [],
51
+ edits: [],
52
+ sendMessage: async (chatId, text) => {
53
+ const message_id = nextId++
54
+ fb.sent.push({ chatId, messageId: message_id, text })
55
+ return { message_id }
56
+ },
57
+ editMessageText: async (_chatId, messageId, text) => {
58
+ fb.edits.push({ messageId, text })
59
+ return {}
60
+ },
61
+ }
62
+ return fb
63
+ }
64
+
65
+ /**
66
+ * Mirror the gateway's status-pin reconcile: a `Map<key,PinState>` claim store
67
+ * driven through the real `reconcilePin` driver against a fake pin API that
68
+ * records every pin/unpin call. This is the seam that turns a `reconcilePin`
69
+ * hook call into an observable Telegram pin/unpin — proving OUTCOMES, not paths.
70
+ */
71
+ function makePinHarness() {
72
+ const state = new Map<string, PinState>()
73
+ const pinCalls: number[] = []
74
+ const unpinCalls: number[] = []
75
+ let lastKey: string | null = null
76
+ const api: PinBotApi = {
77
+ pinChatMessage: async (_chat, message_id) => {
78
+ pinCalls.push(message_id)
79
+ },
80
+ unpinChatMessage: async (_chat, message_id) => {
81
+ unpinCalls.push(message_id)
82
+ },
83
+ }
84
+ async function reconcile(key: string, chatId: string, desired: DesiredPin): Promise<void> {
85
+ const prev = state.get(key) ?? null
86
+ const next = await reconcilePin({ api, chatId, prevState: prev, desired })
87
+ if (next == null) state.delete(key)
88
+ else state.set(key, next)
89
+ }
90
+ const reconcilePinFn = (args: {
91
+ feedKey: string
92
+ chatId: string
93
+ threadId?: number
94
+ messageId: number | null
95
+ }): void => {
96
+ const key = `wk:group:${args.feedKey}`
97
+ lastKey = key
98
+ if (args.messageId != null) void reconcile(key, args.chatId, { pinned: true, messageId: args.messageId })
99
+ else void reconcile(key, args.chatId, { pinned: false })
100
+ }
101
+ return {
102
+ state,
103
+ pinCalls,
104
+ unpinCalls,
105
+ reconcilePinFn,
106
+ key: (): string => {
107
+ if (lastKey == null) throw new Error('no reconcile happened yet')
108
+ return lastKey
109
+ },
110
+ /** Directly drop the claim to simulate a pin lost out-of-band. */
111
+ dropClaim(): void {
112
+ state.clear()
113
+ },
114
+ }
115
+ }
116
+
117
+ /** Flush the microtask queue so fire-and-forget `void reconcile(...)` settles. */
118
+ async function flush(): Promise<void> {
119
+ for (let i = 0; i < 8; i++) await new Promise((r) => setImmediate(r))
120
+ }
121
+
122
+ describe('worker-feed pin persistence — steady-state re-pin (invisible-worker-cards)', () => {
123
+ it('re-pins on a steady-state edit when the claim was lost, and does NOT re-pin when already correct (no storm)', async () => {
124
+ const bot = makeFakeBot()
125
+ const pin = makePinHarness()
126
+ let clock = 0
127
+ const feed = createWorkerActivityFeed({
128
+ bot,
129
+ now: () => clock,
130
+ firstPaintMinMs: 0,
131
+ minEditIntervalMs: 0,
132
+ reconcilePin: pin.reconcilePinFn,
133
+ })
134
+
135
+ // First paint → message posted AND pinned once.
136
+ clock = 1000
137
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
138
+ await flush()
139
+ expect(bot.sent).toHaveLength(1)
140
+ const msgId = bot.sent[0].messageId
141
+ expect(pin.pinCalls).toEqual([msgId])
142
+ expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
143
+
144
+ // Steady-state edit while the pin is already correct → NO new pin (no storm).
145
+ clock = 2000
146
+ await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 5 }))
147
+ await flush()
148
+ expect(bot.edits.length).toBeGreaterThanOrEqual(1)
149
+ expect(pin.pinCalls).toEqual([msgId]) // still exactly one pin — decidePinAction no-op'd
150
+
151
+ // Pin lost out-of-band (claim dropped) → next steady edit must re-pin.
152
+ pin.dropClaim()
153
+ clock = 3000
154
+ await feed.update('w1', 'chat', view({ elapsedMs: 3000, toolCount: 9 }))
155
+ await flush()
156
+ expect(pin.pinCalls).toEqual([msgId, msgId]) // re-pinned the SAME live message
157
+ expect(pin.state.get(pin.key())?.messageId).toBe(msgId)
158
+ })
159
+ })
160
+
161
+ describe('worker-feed pin persistence — unpin clears the claim', () => {
162
+ it('an unpin routed through the reconciler clears the claim so a later edit re-pins', async () => {
163
+ const bot = makeFakeBot()
164
+ const pin = makePinHarness()
165
+ let clock = 0
166
+ const feed = createWorkerActivityFeed({
167
+ bot,
168
+ now: () => clock,
169
+ firstPaintMinMs: 0,
170
+ minEditIntervalMs: 0,
171
+ reconcilePin: pin.reconcilePinFn,
172
+ })
173
+
174
+ clock = 1000
175
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
176
+ await flush()
177
+ const msgId = bot.sent[0].messageId
178
+ const key = pin.key()
179
+ expect(pin.state.get(key)?.messageId).toBe(msgId)
180
+
181
+ // Route an unpin of the worker message through the reconciler (the sanctioned
182
+ // path): the claim is dropped AND the Telegram unpin is issued.
183
+ await (async () => {
184
+ const prev = pin.state.get(key) ?? null
185
+ const next = await reconcilePin({
186
+ api: {
187
+ pinChatMessage: async () => {},
188
+ unpinChatMessage: async (_c, id) => {
189
+ pin.unpinCalls.push(id)
190
+ },
191
+ },
192
+ chatId: 'chat',
193
+ prevState: prev,
194
+ desired: { pinned: false },
195
+ })
196
+ if (next == null) pin.state.delete(key)
197
+ else pin.state.set(key, next)
198
+ })()
199
+ expect(pin.state.has(key)).toBe(false) // claim cleared
200
+ expect(pin.unpinCalls).toContain(msgId)
201
+
202
+ // A later steady edit now re-pins (the claim being clear is what lets
203
+ // decidePinAction issue a fresh pin instead of a stale-id no-op).
204
+ clock = 2000
205
+ await feed.update('w1', 'chat', view({ elapsedMs: 2000, toolCount: 7 }))
206
+ await flush()
207
+ expect(pin.pinCalls).toContain(msgId)
208
+ expect(pin.state.get(key)?.messageId).toBe(msgId)
209
+ })
210
+ })
211
+
212
+ describe('worker-feed pin persistence — group-message lifetime cap rotation', () => {
213
+ it('rotates to a fresh message past the cap and re-establishes the pin on the new id', async () => {
214
+ const bot = makeFakeBot()
215
+ const pin = makePinHarness()
216
+ let clock = 0
217
+ const feed = createWorkerActivityFeed({
218
+ bot,
219
+ now: () => clock,
220
+ firstPaintMinMs: 0,
221
+ minEditIntervalMs: 0,
222
+ heartbeatTickMs: 6000,
223
+ groupMessageLifetimeCapMs: 30_000,
224
+ reconcilePin: pin.reconcilePinFn,
225
+ })
226
+
227
+ // First paint → message A posted + pinned.
228
+ clock = 1000
229
+ await feed.update('w1', 'chat', view({ elapsedMs: 1000, toolCount: 1 }))
230
+ await flush()
231
+ const msgA = bot.sent[0].messageId
232
+ expect(pin.pinCalls).toEqual([msgA])
233
+
234
+ // Worker keeps running; advance well past the 30s message cap, then tick.
235
+ clock = 40_000
236
+ await feed.update('w1', 'chat', view({ elapsedMs: 40_000, toolCount: 4 }))
237
+ await flush()
238
+ feed.heartbeatTick()
239
+ await flush()
240
+
241
+ // A SECOND message was posted (rotation) and it is a DIFFERENT id.
242
+ expect(bot.sent).toHaveLength(2)
243
+ const msgB = bot.sent[1].messageId
244
+ expect(msgB).not.toBe(msgA)
245
+ expect(feed.messageIdOf('w1')).toBe(msgB)
246
+
247
+ // The stale message was unpinned and the fresh one pinned → pin surface
248
+ // re-established on the new id (deterministic, no burst: one unpin + one pin).
249
+ expect(pin.unpinCalls).toContain(msgA)
250
+ expect(pin.pinCalls).toContain(msgB)
251
+ expect(pin.state.get(pin.key())?.messageId).toBe(msgB)
252
+
253
+ // FIX 1: the retired message was collapsed to the honest "moved" note with
254
+ // exactly ONE edit (not left frozen showing live rows, not re-edited per tick).
255
+ const supersedeEdits = bot.edits.filter(
256
+ (e) => e.messageId === msgA && e.text.includes('Live progress moved to a fresh card'),
257
+ )
258
+ expect(supersedeEdits).toHaveLength(1)
259
+ })
260
+
261
+ it('rotation preserves ALL live overlapping worker rows on the fresh message (none dropped/double-counted)', async () => {
262
+ const bot = makeFakeBot()
263
+ const pin = makePinHarness()
264
+ let clock = 0
265
+ const feed = createWorkerActivityFeed({
266
+ bot,
267
+ now: () => clock,
268
+ firstPaintMinMs: 0,
269
+ minEditIntervalMs: 0,
270
+ heartbeatTickMs: 6000,
271
+ groupMessageLifetimeCapMs: 30_000,
272
+ reconcilePin: pin.reconcilePinFn,
273
+ })
274
+
275
+ // Two workers overlap in the SAME chat → one combined group message.
276
+ clock = 1000
277
+ await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 1000 }))
278
+ await flush()
279
+ clock = 1100
280
+ await feed.update('w2', 'chat', view({ description: 'beta task', elapsedMs: 1100 }))
281
+ await flush()
282
+ const msgA = bot.sent[0].messageId
283
+ // Both workers share the one message.
284
+ expect(feed.messageIdOf('w1')).toBe(msgA)
285
+ expect(feed.messageIdOf('w2')).toBe(msgA)
286
+
287
+ // Advance past the cap with BOTH still live, then tick to rotate.
288
+ clock = 40_000
289
+ await feed.update('w1', 'chat', view({ description: 'alpha task', elapsedMs: 40_000, toolCount: 3 }))
290
+ await flush()
291
+ feed.heartbeatTick()
292
+ await flush()
293
+
294
+ // A fresh message was posted and BOTH live workers moved to it — no row
295
+ // dropped, no worker orphaned on the retired card.
296
+ expect(bot.sent).toHaveLength(2)
297
+ const fresh = bot.sent[1]
298
+ expect(fresh.messageId).not.toBe(msgA)
299
+ expect(feed.messageIdOf('w1')).toBe(fresh.messageId)
300
+ expect(feed.messageIdOf('w2')).toBe(fresh.messageId)
301
+ // The fresh combined body renders BOTH workers' rows.
302
+ expect(fresh.text).toContain('alpha task')
303
+ expect(fresh.text).toContain('beta task')
304
+ expect(feed.size).toBe(2)
305
+ })
306
+ })
@@ -115,6 +115,11 @@ export interface SessionActivityHeader {
115
115
  * `formatModelLabel` (model-label.ts).
116
116
  */
117
117
  model?: string
118
+ /** Running total tokens for THIS turn (the parent agent's OWN per-message
119
+ * usage, summed + deduped upstream) — rendered as `· {N} tok` on the metrics
120
+ * line via `tokenSegment`. Omitted (0/undefined) → no token segment, same
121
+ * clean-omit behavior as the worker feed. */
122
+ totalTokens?: number
118
123
  }
119
124
 
120
125
  /**
@@ -185,21 +190,57 @@ export function renderActivityHeader(
185
190
  toolCount: number,
186
191
  state: 'running' | 'done' | 'failed' | 'incomplete',
187
192
  model?: string,
193
+ totalTokens?: number,
188
194
  ): [string, string] {
189
195
  const toolWord = toolCount === 1 ? 'tool' : 'tools'
190
196
  const elapsed = formatFeedElapsed(elapsedMs)
191
197
  const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
192
198
  const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
199
+ // Running total tokens: joins the dot-separated metrics between the tool
200
+ // count and the model tag. Omitted (empty) when the total is 0/unknown.
201
+ const tokPart = tokenSegment(totalTokens)
193
202
  // Subtle live-model tag: joins the existing dot-separated metrics (never a new
194
203
  // line). formatModelLabel returns null for absent/sentinel values → no suffix.
195
204
  const modelLabel = formatModelLabel(model)
196
205
  const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
197
206
  const line2 = state === 'running'
198
- ? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
199
- : `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
207
+ ? `_${elapsed} · ${toolCount} ${toolWord}${tokPart}${modelPart}_`
208
+ : `_${state} · ${toolCount} ${toolWord}${tokPart} · ${elapsed}${modelPart}_`
200
209
  return [line1, line2]
201
210
  }
202
211
 
212
+ /**
213
+ * Compact token-count formatter for the activity card's metrics line:
214
+ * <1000 → raw ("940")
215
+ * ≥1000, <1e6 → one-decimal k ("12.4k", "1.0k")
216
+ * ≥1e6 → one-decimal M ("1.2M")
217
+ * Negative / non-finite inputs clamp to "0". The caller appends " tok".
218
+ */
219
+ export function formatTokenCount(n: number): string {
220
+ if (!Number.isFinite(n) || n <= 0) return '0'
221
+ if (n < 1000) return String(Math.floor(n))
222
+ if (n < 1_000_000) {
223
+ // Round to the displayed 1-decimal k FIRST: inputs in [999_950, 999_999]
224
+ // round to "1000.0k", which must promote into the M branch rather than
225
+ // render a nonsense "1000.0k". Fall through when the rounded k reaches 1000.
226
+ const k = Number((n / 1000).toFixed(1))
227
+ if (k < 1000) return `${k.toFixed(1)}k`
228
+ }
229
+ return `${(n / 1_000_000).toFixed(1)}M`
230
+ }
231
+
232
+ /**
233
+ * The ` · {N} tok` metrics segment, or '' when there are no tokens to show.
234
+ * A 0 / undefined total (a worker that emitted no usage — e.g. a non-Claude
235
+ * transcript) OMITS the segment entirely so the line stays clean; the same
236
+ * predicate is used by BOTH render variants (single-worker header + combined
237
+ * row) so they never diverge.
238
+ */
239
+ function tokenSegment(totalTokens: number | undefined): string {
240
+ if (totalTokens == null || totalTokens <= 0) return ''
241
+ return ` · ${formatTokenCount(totalTokens)} tok`
242
+ }
243
+
203
244
  /** Format elapsed milliseconds for display in the activity header (e.g. "12s", "2m05s"). */
204
245
  export function formatFeedElapsed(ms: number): string {
205
246
  const s = Math.floor(ms / 1000)
@@ -289,6 +330,9 @@ export interface StatusCardHeader {
289
330
  /** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
290
331
  * tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
291
332
  model?: string
333
+ /** Running total tokens for the worker/turn — rendered as `· {N} tok` on the
334
+ * metrics line. Omitted (0/undefined) → no token segment. */
335
+ totalTokens?: number
292
336
  }
293
337
 
294
338
  /** Inputs to the unified status-card renderer. */
@@ -341,6 +385,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
341
385
  // 'failed', so only the worker card is affected.
342
386
  header.state,
343
387
  header.model,
388
+ header.totalTokens,
344
389
  )
345
390
  : []
346
391
 
@@ -497,6 +542,7 @@ export function renderActivityFeed(
497
542
  toolCount: header.toolCount,
498
543
  state: header.state,
499
544
  model: header.model,
545
+ totalTokens: header.totalTokens,
500
546
  }
501
547
  : undefined,
502
548
  steps: lines,
@@ -549,6 +595,7 @@ export function renderActivityFeedWithNested(
549
595
  toolCount: header.toolCount,
550
596
  state: header.state,
551
597
  model: header.model,
598
+ totalTokens: header.totalTokens,
552
599
  }
553
600
  : undefined,
554
601
  steps: lines,
@@ -596,6 +643,9 @@ export interface CombinedWorkerRow {
596
643
  historyLines?: string[]
597
644
  /** Live model id (raw, e.g. `claude-opus-4-8`); omitted when unknown. */
598
645
  model?: string
646
+ /** Running total tokens for this worker — rendered as `· {N} tok` on the row
647
+ * header. Omitted (0/undefined) → no token segment. */
648
+ totalTokens?: number
599
649
  }
600
650
 
601
651
  export interface CombinedWorkerFeedOpts {
@@ -671,9 +721,10 @@ export function renderCombinedWorkerFeed(
671
721
  truncate(stripMarkdown(r.description).replace(/\s+/g, ' ').trim() || 'background task', COMBINED_ROW_DESC_MAX),
672
722
  )
673
723
  const toolWord = r.toolCount === 1 ? 'tool' : 'tools'
724
+ const tokPart = tokenSegment(r.totalTokens)
674
725
  const modelLabel = formatModelLabel(r.model)
675
726
  const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
676
- return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${modelPart}_`
727
+ return `**${desc}** _· ${formatFeedElapsed(r.elapsedMs)} · ${r.toolCount} ${toolWord}${tokPart}${modelPart}_`
677
728
  }
678
729
 
679
730
  // Raw (unescaped) history for a worker, oldest→newest, empty lines stripped.
@@ -92,6 +92,14 @@ export interface WorkerActivityView {
92
92
  lastTool: { name: string; sanitisedArg: string } | null
93
93
  /** Number of tool calls observed so far. */
94
94
  toolCount: number
95
+ /**
96
+ * Running TOTAL tokens across the worker's assistant messages so far
97
+ * (input + output + cache_creation, deduped by message.id in the watcher;
98
+ * cache_read is excluded — replayed cached context, not new work). Rendered
99
+ * as `· {N} tok` on the card's metrics line. Omitted /
100
+ * 0 → no token segment (a worker that emitted no usage).
101
+ */
102
+ totalTokens?: number
95
103
  /** The worker's latest narrative line, if any (already capped upstream). */
96
104
  latestSummary: string
97
105
  /**
@@ -178,6 +186,7 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
178
186
  toolCount: v.toolCount,
179
187
  state: v.state,
180
188
  model: v.model,
189
+ totalTokens: v.totalTokens,
181
190
  }
182
191
 
183
192
  // Terminal: latestSummary carries the worker's final result text (gateway
@@ -328,6 +337,29 @@ export interface WorkerActivityFeedOpts {
328
337
  * Tests inject a small value.
329
338
  */
330
339
  absoluteRowLifetimeCapMs?: number
340
+ /**
341
+ * ABSOLUTE reused-group-MESSAGE lifetime cap (ms), measured from the moment
342
+ * the shared message was posted (`messageCreatedAtMs`), NOT from a worker
343
+ * row's age. When workers overlap continuously the group is NEVER reset to a
344
+ * fresh message (the only messageId reset is the group-reuse-after-terminal
345
+ * branch, gated on `!hasLiveWorker`), so ONE message can live for hours. If
346
+ * that message loses its pin out-of-band (a raw unpin somewhere else, or
347
+ * Telegram pin-stacking burying it), the steady-state edit path re-pins via
348
+ * `syncPin`; but a STALE in-memory pin claim (the claim still names this id, so
349
+ * `decidePinAction` no-ops on equal id) can only be cleared by rotating to a
350
+ * NEW message id. This cap force-rotates the shared message past an absolute
351
+ * age WHILE live workers remain, so the pin surface is periodically re-
352
+ * established through the first-paint `syncPin` path — the deterministic
353
+ * defense-in-depth that bounds how long a buried card can stay invisible
354
+ * (invisible-worker-cards incident, 2026-07-15; mirrors the immutable-anchor
355
+ * absolute-cap pattern of `absoluteRowLifetimeCapMs` / #3239's row cap).
356
+ *
357
+ * Conservative by design: set well above a normal edit cadence so a healthy
358
+ * pinned card is not needlessly churned. Rotation costs ONE unpin (old claim)
359
+ * + ONE pin (fresh message) per cap interval — never a burst. Fallback default
360
+ * (this module) 60 min; tests inject a small value.
361
+ */
362
+ groupMessageLifetimeCapMs?: number
331
363
  /**
332
364
  * Group-level status-pin reconcile hook (#3207 review). Because workers now
333
365
  * COALESCE into one shared message, the pin MUST follow the GROUP lifecycle,
@@ -425,6 +457,15 @@ interface FeedGroup {
425
457
  chatId: string
426
458
  threadId?: number
427
459
  messageId: number | null
460
+ /**
461
+ * Wall-clock ms the CURRENT shared message was posted (first paint / re-paint).
462
+ * IMMUTABLE for the lifetime of a given `messageId`: stamped when a message id
463
+ * is assigned and NEVER re-stamped by later edits, so the group-message
464
+ * lifetime cap (`groupMessageLifetimeCapMs`) measures the reused message's true
465
+ * absolute age and cannot be reset by a continuous edit stream. Reset to 0
466
+ * whenever `messageId` goes null (rotation / stale-message drop).
467
+ */
468
+ messageCreatedAtMs: number
428
469
  lastBody: string | null
429
470
  lastEditAt: number
430
471
  cooldownUntil: number
@@ -460,6 +501,17 @@ interface FeedGroup {
460
501
 
461
502
  const COOLDOWN_JITTER_MS = 500
462
503
 
504
+ /**
505
+ * Static body the retired shared message is finalized to when the group-message
506
+ * lifetime cap rotates to a fresh card (invisible-worker-cards review, FIX 1).
507
+ * Without this the abandoned message stays frozen showing live-styled worker
508
+ * rows and reads like a stuck worker. A single best-effort edit collapses it to
509
+ * an honest "moved" note — issued once per rotation (≥ cap interval), never per
510
+ * tick, so it adds no edit churn / pin storm. Plain voice, no em dash.
511
+ */
512
+ const WORKER_CARD_SUPERSEDED_BODY =
513
+ '🛠 **Worker** · _continued_\n\n_Live progress moved to a fresh card to stay pinned._'
514
+
463
515
  function extractRetryAfterSecs(err: unknown): number | null {
464
516
  if (err == null || typeof err !== 'object') return null
465
517
  const e = err as { error_code?: unknown; parameters?: { retry_after?: unknown } }
@@ -600,6 +652,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
600
652
  const maxRows = Math.max(1, Math.floor(opts.maxRows ?? 8))
601
653
  const staleWorkerTtlMs = Math.max(1, Math.floor(opts.staleWorkerTtlMs ?? 50 * 60_000))
602
654
  const absoluteRowLifetimeCapMs = Math.max(1, Math.floor(opts.absoluteRowLifetimeCapMs ?? 6 * 60 * 60_000))
655
+ const groupMessageLifetimeCapMs = Math.max(1, Math.floor(opts.groupMessageLifetimeCapMs ?? 60 * 60_000))
603
656
  const reconcilePinFn = opts.reconcilePin ?? (() => {})
604
657
  const setIntervalFn =
605
658
  opts.setInterval ??
@@ -754,6 +807,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
754
807
  description: v.description,
755
808
  elapsedMs: elapsedFor(r),
756
809
  toolCount: v.toolCount,
810
+ totalTokens: v.totalTokens,
757
811
  currentStep,
758
812
  // Full per-worker rolling history (oldest→newest) so the combined feed
759
813
  // can paint an adaptive-depth ✓/→ trail, not just the latest line. The
@@ -903,6 +957,9 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
903
957
  return
904
958
  }
905
959
  g.messageId = sent.message_id
960
+ // Stamp the reused-message birth time (immutable for this id) so the
961
+ // group-message lifetime cap measures the message's true absolute age.
962
+ g.messageCreatedAtMs = now
906
963
  g.lastBody = body
907
964
  g.lastEditAt = now
908
965
  // A fresh message is a live running paint, never a terminal recap.
@@ -951,6 +1008,16 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
951
1008
  `worker-feed: edit feed=${g.feedKey} chat=${g.chatId} ` +
952
1009
  `thread=${g.threadId ?? '-'} msgId=${g.messageId} workers=${g.workers.size} bytes=${body.length}`,
953
1010
  )
1011
+ // Re-assert the group pin on the steady-state edit path (invisible-
1012
+ // worker-cards fix). `syncPin` was previously called ONLY at lifecycle
1013
+ // edges (first-paint / terminal / GC), so once a long-lived reused
1014
+ // message lost its pin out-of-band it was edited live forever but never
1015
+ // re-pinned → scroll-buried. This closes that gap deterministically. It
1016
+ // is a NO-OP when the pin is already correct: `decidePinAction` returns
1017
+ // `noop` on equal claim id (status-pin.ts), so this makes ZERO Telegram
1018
+ // calls on the common path and cannot create a pin storm — when the
1019
+ // claim was dropped (variant 1) it re-pins the current message.
1020
+ syncPin(g)
954
1021
  }
955
1022
  if (isTerminal) clearStaged()
956
1023
  } catch (err) {
@@ -971,6 +1038,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
971
1038
  // stale message id; a fresh first-paint re-establishes one if workers
972
1039
  // are still live. On a terminal finalize, clear the staged repaint.
973
1040
  g.messageId = null
1041
+ g.messageCreatedAtMs = 0
974
1042
  g.lastBody = null
975
1043
  // The pinned message no longer exists → release the group pin claim
976
1044
  // (clearStaged already re-syncs on the terminal path).
@@ -1054,6 +1122,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1054
1122
  description: lv?.description ?? 'background task',
1055
1123
  lastTool: null,
1056
1124
  toolCount: lv?.toolCount ?? 0,
1125
+ totalTokens: lv?.totalTokens,
1057
1126
  // No fabricated result paragraph — an authoritative sweep can't know what
1058
1127
  // the worker returned; the terminal card shows the header struck-through
1059
1128
  // as `incomplete`, and the handback (if it ran) carries the actual result.
@@ -1152,6 +1221,39 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1152
1221
  const running = runningRows(g)
1153
1222
  if (running.length === 0) continue
1154
1223
 
1224
+ // ABSOLUTE group-message lifetime cap (invisible-worker-cards fix, defense
1225
+ // in depth). While workers overlap continuously the shared message is
1226
+ // never reset, so one message can live for hours; if its pin was lost
1227
+ // out-of-band and the in-memory claim went STALE (claim still names this
1228
+ // id → `decidePinAction` no-ops on equal id), `syncPin` alone can never
1229
+ // re-pin it. Force-rotate to a fresh message past an absolute age WHILE
1230
+ // live workers remain: drop the id (unpinning the stale claim via
1231
+ // `syncPin`, which clears it to null), then fall through to the first-
1232
+ // paint path, whose `syncPin` re-pins the NEW message from a null claim.
1233
+ // Costs exactly one unpin + one pin per cap interval — never a burst.
1234
+ if (g.messageId != null && now - g.messageCreatedAtMs >= groupMessageLifetimeCapMs) {
1235
+ const age = Math.floor((now - g.messageCreatedAtMs) / 1000)
1236
+ const retiredId = g.messageId
1237
+ log(
1238
+ `worker-feed: group-message lifetime cap rotate feed=${g.feedKey} ` +
1239
+ `msgId=${retiredId} — age ${age}s (>= ${Math.floor(groupMessageLifetimeCapMs / 1000)}s); ` +
1240
+ `rotating to a fresh message to re-establish the pin surface`,
1241
+ )
1242
+ g.messageId = null
1243
+ g.messageCreatedAtMs = 0
1244
+ g.lastBody = null
1245
+ // Clear the (possibly stale) pin claim for the retired message; the
1246
+ // fresh first-paint below re-pins the new one from a null claim.
1247
+ syncPin(g)
1248
+ // FIX 1: collapse the retired message to an honest "moved" note so it
1249
+ // doesn't sit frozen showing live-styled rows (mistakable for a stuck
1250
+ // worker). ONE best-effort edit per rotation (≥ cap interval), fired
1251
+ // off-chain and swallowing errors — never per-tick, never a burst.
1252
+ void opts.bot
1253
+ .editMessageText(g.chatId, retiredId, WORKER_CARD_SUPERSEDED_BODY, sendOptsFor(g))
1254
+ .catch(() => {})
1255
+ }
1256
+
1155
1257
  // First-paint path: no message yet and some worker has now crossed
1156
1258
  // firstPaintMin (a prose-silent worker's single early tick was held).
1157
1259
  if (g.messageId == null) {
@@ -1218,6 +1320,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1218
1320
  chatId,
1219
1321
  threadId,
1220
1322
  messageId: null,
1323
+ messageCreatedAtMs: 0,
1221
1324
  lastBody: null,
1222
1325
  lastEditAt: 0,
1223
1326
  cooldownUntil: 0,
@@ -1237,6 +1340,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
1237
1340
  // live worker remains AND the group last went terminal.
1238
1341
  if (!g.workers.has(agentId) && g.terminalPainted && !hasLiveWorker(g)) {
1239
1342
  g.messageId = null
1343
+ g.messageCreatedAtMs = 0
1240
1344
  g.lastBody = null
1241
1345
  g.pendingFinalize.clear()
1242
1346
  g.terminalPainted = false