switchroom 0.18.23 → 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 (45) 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 +1608 -841
  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 +524 -16
  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/reply-owner-resolve.ts +160 -0
  15. package/telegram-plugin/session-tail.ts +185 -0
  16. package/telegram-plugin/subagent-watcher.ts +45 -0
  17. package/telegram-plugin/tests/crash-redelivery-resume-exclusion.test.ts +133 -0
  18. package/telegram-plugin/tests/crash-redelivery-wiring.test.ts +72 -0
  19. package/telegram-plugin/tests/history.test.ts +91 -0
  20. package/telegram-plugin/tests/model-command.test.ts +189 -12
  21. package/telegram-plugin/tests/redelivery-decision.test.ts +84 -0
  22. package/telegram-plugin/tests/registry-turns.test.ts +51 -0
  23. package/telegram-plugin/tests/reply-owner-resolve.test.ts +279 -0
  24. package/telegram-plugin/tests/session-model-source.test.ts +11 -0
  25. package/telegram-plugin/tests/session-tail.test.ts +145 -0
  26. package/telegram-plugin/tests/subagent-watcher.test.ts +50 -0
  27. package/telegram-plugin/tests/tool-activity-summary.test.ts +109 -0
  28. package/telegram-plugin/tests/trailing-answer-projector.test.ts +124 -0
  29. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +125 -0
  30. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +117 -1
  31. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +306 -0
  32. package/telegram-plugin/tool-activity-summary.ts +54 -3
  33. package/telegram-plugin/worker-activity-feed.ts +222 -10
  34. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +762 -0
  35. package/vendor/hindsight-memory/scripts/drain_pending.py +13 -1
  36. package/vendor/hindsight-memory/scripts/lib/client.py +14 -4
  37. package/vendor/hindsight-memory/scripts/lib/config.py +8 -0
  38. package/vendor/hindsight-memory/scripts/lib/pacing.py +102 -0
  39. package/vendor/hindsight-memory/scripts/lib/watermark.py +213 -0
  40. package/vendor/hindsight-memory/scripts/reconcile_tail.py +344 -0
  41. package/vendor/hindsight-memory/scripts/retain.py +299 -143
  42. package/vendor/hindsight-memory/scripts/session_start.py +14 -0
  43. package/vendor/hindsight-memory/scripts/tests/test_backfill.py +362 -0
  44. package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +350 -0
  45. 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.