switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -10,10 +10,18 @@
10
10
  */
11
11
 
12
12
  import { describe, it, expect } from 'bun:test'
13
+ import { readFileSync } from 'node:fs'
14
+ import { fileURLToPath } from 'node:url'
13
15
  import {
14
16
  transcribeViaWhisper,
15
17
  OPENAI_WHISPER_MAX_BYTES,
16
18
  } from '../voice-transcribe.js'
19
+ import {
20
+ materializeVoiceKey,
21
+ DEFAULT_VOICE_API_KEY_REF,
22
+ } from '../../src/telegram/materialize-voice-key.js'
23
+ import type { resolveVaultReferencesViaBroker } from '../../src/vault/resolver.js'
24
+ import type { SwitchroomConfig } from '../../src/config/schema.js'
17
25
 
18
26
  const SMALL_AUDIO = new Uint8Array([0x4f, 0x67, 0x67, 0x53]) // OggS magic; not real audio
19
27
 
@@ -194,3 +202,183 @@ describe('transcribeViaWhisper — error paths', () => {
194
202
  expect(r).toMatchObject({ ok: false, reason: 'timeout' })
195
203
  })
196
204
  })
205
+
206
+ // ── PR-A: STT key resolves via the vault broker, not a plaintext file ──
207
+
208
+ const EMPTY_CONFIG = {} as unknown as SwitchroomConfig
209
+
210
+ /** A fake broker resolver that returns the value mapped to bot_token (the
211
+ * key materializeVoiceKey narrows the reference onto). */
212
+ function makeBrokerResolver(
213
+ resolvedRefs: Record<string, string>,
214
+ ): typeof resolveVaultReferencesViaBroker {
215
+ return (async (config: SwitchroomConfig) => {
216
+ const ref = config.telegram?.bot_token
217
+ if (ref && resolvedRefs[ref] !== undefined) {
218
+ return {
219
+ ok: true,
220
+ config: {
221
+ ...config,
222
+ telegram: { ...config.telegram, bot_token: resolvedRefs[ref] },
223
+ },
224
+ }
225
+ }
226
+ return { ok: false, reason: 'not_found' as const }
227
+ }) as unknown as typeof resolveVaultReferencesViaBroker
228
+ }
229
+
230
+ describe('materializeVoiceKey — vault resolution', () => {
231
+ it('resolves the configured vault ref via the broker', async () => {
232
+ const key = await materializeVoiceKey({
233
+ apiKeyRef: 'vault:openai/api-key',
234
+ config: EMPTY_CONFIG,
235
+ env: {},
236
+ resolveViaBroker: makeBrokerResolver({
237
+ 'vault:openai/api-key': 'sk-' + 'resolved' + '-key',
238
+ }),
239
+ })
240
+ expect(key).toBe('sk-' + 'resolved' + '-key')
241
+ })
242
+
243
+ it('defaults to vault:openai/api-key when no ref is configured', async () => {
244
+ expect(DEFAULT_VOICE_API_KEY_REF).toBe('vault:openai/api-key')
245
+ const key = await materializeVoiceKey({
246
+ apiKeyRef: undefined,
247
+ config: EMPTY_CONFIG,
248
+ env: {},
249
+ resolveViaBroker: makeBrokerResolver({
250
+ 'vault:openai/api-key': 'sk-' + 'default' + '-key',
251
+ }),
252
+ })
253
+ expect(key).toBe('sk-' + 'default' + '-key')
254
+ })
255
+
256
+ it('returns a literal (non-vault) key directly without touching the broker', async () => {
257
+ let brokerCalled = false
258
+ const key = await materializeVoiceKey({
259
+ apiKeyRef: 'sk-' + 'literal',
260
+ config: EMPTY_CONFIG,
261
+ env: {},
262
+ resolveViaBroker: (async () => {
263
+ brokerCalled = true
264
+ return { ok: false, reason: 'not_found' as const }
265
+ }) as unknown as typeof resolveVaultReferencesViaBroker,
266
+ })
267
+ expect(key).toBe('sk-' + 'literal')
268
+ expect(brokerCalled).toBe(false)
269
+ })
270
+
271
+ it('returns null (graceful fallback, no throw) when the key is unresolvable', async () => {
272
+ const key = await materializeVoiceKey(
273
+ {
274
+ apiKeyRef: 'vault:openai/api-key',
275
+ config: EMPTY_CONFIG,
276
+ env: {}, // no SWITCHROOM_VAULT_PASSPHRASE → no direct-decrypt fallback
277
+ resolveViaBroker: makeBrokerResolver({}), // broker resolves nothing
278
+ },
279
+ () => {}, // swallow the diagnostic log
280
+ )
281
+ expect(key).toBeNull()
282
+ })
283
+
284
+ // ── Regression: single-key isolation (voice token bug) ──
285
+ //
286
+ // The batch resolver (resolveVaultReferencesViaBroker) fails the WHOLE
287
+ // resolution unless EVERY vault: ref it finds resolves. Admin-only refs
288
+ // like litellm.admin_key / google_workspace.google_client_secret are
289
+ // (correctly) DENIED to a normal agent, so if the materializer passes the
290
+ // full config those denials sink the resolution even though openai/api-key
291
+ // itself is granted. The fix passes a MINIMAL config carrying only the one
292
+ // ref. These tests would fail against the old `...config` spread.
293
+
294
+ /** Regex of every vault: ref the resolver sees; denies if it sees any admin-only key. */
295
+ function collectRefs(v: unknown, out: Set<string>): void {
296
+ if (typeof v === 'string' && v.startsWith('vault:')) out.add(v.slice('vault:'.length).split('#')[0])
297
+ else if (Array.isArray(v)) for (const i of v) collectRefs(i, out)
298
+ else if (v !== null && typeof v === 'object') for (const val of Object.values(v as Record<string, unknown>)) collectRefs(val, out)
299
+ }
300
+
301
+ /**
302
+ * A resolver stub with the SAME batch semantics as the real one: it fails
303
+ * (denied) unless every ref it collects is in `granted`. Mirrors how a real
304
+ * broker denies admin-only keys to a normal agent.
305
+ */
306
+ function makeBatchResolver(
307
+ granted: Record<string, string>,
308
+ onCall?: (refs: Set<string>) => void,
309
+ ): typeof resolveVaultReferencesViaBroker {
310
+ return (async (config: SwitchroomConfig) => {
311
+ const refs = new Set<string>()
312
+ collectRefs(config, refs)
313
+ onCall?.(refs)
314
+ for (const r of refs) {
315
+ if (granted[`vault:${r}`] === undefined) {
316
+ return { ok: false, reason: 'denied' as const }
317
+ }
318
+ }
319
+ const resolvedToken = granted[config.telegram?.bot_token ?? '']
320
+ return {
321
+ ok: true,
322
+ config: { ...config, telegram: { ...config.telegram, bot_token: resolvedToken } },
323
+ }
324
+ }) as unknown as typeof resolveVaultReferencesViaBroker
325
+ }
326
+
327
+ const CONFIG_WITH_ADMIN_REFS = {
328
+ telegram: { bot_token: 'vault:tg/bot' },
329
+ litellm: { admin_key: 'vault:litellm/master-key' },
330
+ google_workspace: { google_client_secret: 'vault:google/client-secret' },
331
+ agents: { klanker: { some_key: 'vault:per-agent/secret' } },
332
+ vault: { path: '~/.switchroom/vault.enc' },
333
+ } as unknown as SwitchroomConfig
334
+
335
+ it('resolves the STT key even when the config carries admin-only vault refs the broker DENIES', async () => {
336
+ const key = await materializeVoiceKey({
337
+ apiKeyRef: 'vault:openai/api-key',
338
+ config: CONFIG_WITH_ADMIN_REFS,
339
+ env: {},
340
+ // ONLY openai/api-key is granted; litellm/google/per-agent all DENY.
341
+ resolveViaBroker: makeBatchResolver({ 'vault:openai/api-key': 'sk-granted-key' }),
342
+ })
343
+ expect(key).toBe('sk-granted-key')
344
+ })
345
+
346
+ it('invokes the resolver with a config containing exactly ONE vault ref (the STT key)', async () => {
347
+ let seen: Set<string> | null = null
348
+ await materializeVoiceKey({
349
+ apiKeyRef: 'vault:openai/api-key',
350
+ config: CONFIG_WITH_ADMIN_REFS,
351
+ env: {},
352
+ resolveViaBroker: makeBatchResolver(
353
+ { 'vault:openai/api-key': 'sk-granted-key' },
354
+ (refs) => { seen = refs },
355
+ ),
356
+ })
357
+ expect(seen).not.toBeNull()
358
+ expect([...seen!]).toEqual(['openai/api-key'])
359
+ })
360
+ })
361
+
362
+ describe('voice-in — no plaintext key file is read', () => {
363
+ it('the gateway never reads ~/.switchroom/openai-api-key as a path', () => {
364
+ const gatewayPath = fileURLToPath(
365
+ new URL('../gateway/gateway.ts', import.meta.url),
366
+ )
367
+ const src = readFileSync(gatewayPath, 'utf8')
368
+ // The plaintext file basename must not appear as a path segment in
369
+ // any source string (it lived in a join(..., 'openai-api-key')).
370
+ expect(src).not.toContain("'openai-api-key'")
371
+ expect(src).not.toContain('"openai-api-key"')
372
+ })
373
+
374
+ it('the voice-key materializer reads no openai-api-key path', () => {
375
+ const helperPath = fileURLToPath(
376
+ new URL('../../src/telegram/materialize-voice-key.ts', import.meta.url),
377
+ )
378
+ const src = readFileSync(helperPath, 'utf8')
379
+ // The basename appears only inside a code-comment sentence (prose),
380
+ // never quoted as a path literal that fs would read.
381
+ expect(src).not.toContain("'openai-api-key'")
382
+ expect(src).not.toContain('"openai-api-key"')
383
+ })
384
+ })
@@ -115,7 +115,7 @@ describe('renderWorkerActivity', () => {
115
115
  expect(out).toContain('─────')
116
116
  expect(out).toContain('✅ _PR #21 opened_')
117
117
  // latestSummary is the RESULT on the finished path, never also a step.
118
- expect(out).not.toContain('_✓ PR #21 opened_')
118
+ expect(out).not.toContain('~~_✓ PR #21 opened_~~')
119
119
  })
120
120
 
121
121
  it('renders a failed terminal recap', () => {
@@ -155,8 +155,8 @@ describe('renderWorkerActivity', () => {
155
155
  narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
156
156
  }),
157
157
  )
158
- expect(out).toContain('_✓ read the brief_')
159
- expect(out).toContain('_✓ scanned vendor A_')
158
+ expect(out).toContain('~~_✓ read the brief_~~')
159
+ expect(out).toContain('~~_✓ scanned vendor A_~~')
160
160
  expect(out).toContain('**→ scanned vendor B**')
161
161
  // The single-line latestSummary fallback is NOT used when a block is present.
162
162
  expect(out).not.toContain('newest only')
@@ -171,7 +171,7 @@ describe('renderWorkerActivity', () => {
171
171
 
172
172
  it('drops blank narrative lines from the feed', () => {
173
173
  const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
174
- expect(out).toContain('_✓ kept_')
174
+ expect(out).toContain('~~_✓ kept_~~')
175
175
  expect(out).toContain('**→ also kept**')
176
176
  expect(stepCount(out)).toBe(2)
177
177
  })
@@ -183,7 +183,7 @@ describe('renderWorkerActivity', () => {
183
183
  expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
184
184
  expect(out).not.toContain('step 1<')
185
185
  const firstVisible = total - STATUS_ROLLING_LINES + 1
186
- expect(out).toContain(`_✓ step ${firstVisible}_`)
186
+ expect(out).toContain(`~~_✓ step ${firstVisible}_~~`)
187
187
  expect(out).toContain(`**→ step ${total}**`)
188
188
  // STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
189
189
  expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
@@ -191,7 +191,7 @@ describe('renderWorkerActivity', () => {
191
191
 
192
192
  it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
193
193
  // Post-#2669 the card itself is rich markdown — the header is **Worker**
194
- // and steps are _✓ …_ / **→ …**. The per-line pipeline still runs
194
+ // and steps are ~~_✓ …_~~ / **→ …**. The per-line pipeline still runs
195
195
  // stripMarkdown over the user-supplied CONTENT so a model-authored
196
196
  // `**full**` / `` `git push` `` doesn't inject extra emphasis, but the
197
197
  // card's own wrapper markup is expected.
@@ -276,6 +276,24 @@ describe('createWorkerActivityFeed', () => {
276
276
  expect(feed.has('w1')).toBe(true)
277
277
  })
278
278
 
279
+ it('messageIdOf exposes the posted message id (for status-pin) and is null before paint / after finish', async () => {
280
+ const bot = makeFakeBot()
281
+ let clock = 0
282
+ const feed = createWorkerActivityFeed({ bot, now: () => clock, firstPaintMinMs: 8000 })
283
+ // Before paint: no message, no id to pin.
284
+ clock = 5000
285
+ await feed.update('w1', 'chat', view({ elapsedMs: 5000 }))
286
+ expect(feed.messageIdOf('w1')).toBeNull()
287
+ // After paint: id is the posted message the gateway pins.
288
+ clock = 9000
289
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
290
+ // The fake mints message ids starting at 1000; first paint → 1000.
291
+ expect(feed.messageIdOf('w1')).toBe(1000)
292
+ // After finish: handle is dropped → null (gateway unpins independently).
293
+ await feed.finish('w1', view({ state: 'done' }))
294
+ expect(feed.messageIdOf('w1')).toBeNull()
295
+ })
296
+
279
297
  it('dedups an identical body (no edit)', async () => {
280
298
  const bot = makeFakeBot()
281
299
  let clock = 10_000
@@ -406,8 +424,8 @@ describe('createWorkerActivityFeed', () => {
406
424
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
407
425
 
408
426
  const last = bot.edits.at(-1)!
409
- expect(last.text).toContain('_✓ read the brief_')
410
- expect(last.text).toContain('_✓ scanned vendor A_')
427
+ expect(last.text).toContain('~~_✓ read the brief_~~')
428
+ expect(last.text).toContain('~~_✓ scanned vendor A_~~')
411
429
  expect(last.text).toContain('**→ scanned vendor B**')
412
430
  expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
413
431
  })
@@ -462,8 +480,8 @@ describe('createWorkerActivityFeed', () => {
462
480
  clock = 13_000
463
481
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
464
482
  const last = bot.edits.at(-1)!
465
- expect(last.text).toContain('_✓ line A_')
466
- expect(last.text).toContain('_✓ line B_')
483
+ expect(last.text).toContain('~~_✓ line A_~~')
484
+ expect(last.text).toContain('~~_✓ line B_~~')
467
485
  expect(last.text).toContain('**→ line C**')
468
486
  })
469
487
 
@@ -649,6 +667,55 @@ describe('createWorkerActivityFeed — heartbeat', () => {
649
667
  expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
650
668
  })
651
669
 
670
+ it('(i-b) heartbeat repaint keeps the header master elapsed >= the step timer (same clock anchor)', async () => {
671
+ // Ken-observed defect: the heartbeat computed a LIVE `· Ns` step suffix
672
+ // from dispatchAtMs but re-rendered the header from the STALE
673
+ // lastView.elapsedMs (frozen at the last watcher event), so the current
674
+ // step's timer could read MORE than the card's master elapsed. Both must
675
+ // derive from the same anchor at render time.
676
+ const bot = makeFakeBot()
677
+ let clock = 10_000
678
+ const feed = createWorkerActivityFeed({
679
+ bot,
680
+ now: () => clock,
681
+ minEditIntervalMs: 2500,
682
+ heartbeatTickMs: 6000,
683
+ setInterval: () => 1,
684
+ clearInterval: () => {},
685
+ })
686
+ // First paint: view says elapsed 9s → dispatchAt = 19_000 - 9_000 = 10_000.
687
+ clock = 19_000
688
+ await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
689
+ expect(bot.sent).toHaveLength(1)
690
+
691
+ // No watcher events for a long stretch; the heartbeat repaints at 80s.
692
+ clock = 80_000 // live elapsed = 70_000 ms = 1m10s
693
+ feed.heartbeatTick()
694
+ // Drain the handle's promise chain.
695
+ await new Promise((r) => setTimeout(r, 0))
696
+
697
+ const edit = bot.edits[bot.edits.length - 1]
698
+ expect(edit).toBeDefined()
699
+ // Header line 2 (`_{elapsed} · {n} tools_`) must show the LIVE master
700
+ // elapsed — not the stale 9s from the last view.
701
+ const headerMatch = /_(\d+(?:m\d+)?s) · \d+ tools?_/.exec(edit.text)
702
+ expect(headerMatch, `no header elapsed in: ${edit.text}`).not.toBeNull()
703
+ // Step suffix (`· Ns**`) on the in-progress line.
704
+ const stepMatch = /· (\d+(?:m\d+)?s)\*\*/.exec(edit.text)
705
+ expect(stepMatch, `no step suffix in: ${edit.text}`).not.toBeNull()
706
+
707
+ const toMs = (s: string): number => {
708
+ const m = /^(?:(\d+)m)?(\d+)s$/.exec(s)!
709
+ return (m[1] ? Number(m[1]) * 60_000 : 0) + Number(m[2]) * 1000
710
+ }
711
+ const headerMs = toMs(headerMatch![1])
712
+ const stepMs = toMs(stepMatch![1])
713
+ // Outcome: both timers advance together off one anchor — the header is
714
+ // live (not frozen at 9s) and the step never exceeds the master.
715
+ expect(headerMs).toBeGreaterThanOrEqual(stepMs)
716
+ expect(headerMs).toBe(70_000) // 1m10s — refreshed to the live value
717
+ })
718
+
652
719
  it('(ii) respects a 429 cooldown — no edit while cooldownUntil is in the future', async () => {
653
720
  const bot = makeFakeBot()
654
721
  let clock = 10_000
@@ -203,15 +203,27 @@ function restore(
203
203
  * Replace em / en dashes with context-appropriate punctuation.
204
204
  *
205
205
  * Rules, applied in order:
206
- * 1. ` — ` / ` – ` (flanked by single space) → `, ` if followed by a
207
- * lowercase or open-paren character; otherwise `. ` if followed by
208
- * an uppercase or end-of-string. Heuristic: lowercase = mid-clause
209
- * continuation (comma reads naturally); uppercase = new sentence
210
- * (period reads naturally).
206
+ * 1. ` — ` / ` – ` (flanked by single space) → `. ` + the following
207
+ * word recapitalized, in the common case. A spaced em-dash in prose
208
+ * almost always joins two INDEPENDENT clauses ("voice came back —
209
+ * three PRs stacked"); degrading that to a comma produces a comma
210
+ * splice ("voice came back, three PRs stacked"), which reads as
211
+ * broken grammar. A period (full stop) is the substitution that
212
+ * never corrupts meaning: two independent clauses become two
213
+ * sentences. The genuine appositive/parenthetical use of a dash
214
+ * ("the lead — a tall man — spoke") is the minority case and still
215
+ * reads acceptably as two short sentences, so we default to the
216
+ * period rather than risk a splice. A dash already followed by a
217
+ * lowercased conjunction/pronoun that only makes sense mid-sentence
218
+ * is the rare exception the heuristic can't recover; a clean period
219
+ * beats a comma splice there too. Original PR #1683 used a comma for
220
+ * lowercase continuations; that was the #2737-era source of splices
221
+ * in real replies (fixed here).
211
222
  * 2. End-of-line dash (` —\n` / ` –\n`) → `.\n` — treat as full stop.
212
223
  * 3. Bare dash with no flanking spaces between word chars
213
- * (e.g. "word—word") → `, ` the missing-space form is rarer but
214
- * semantically the same as #1.
224
+ * (e.g. "word—word") → `. ` + recapitalized following word the
225
+ * missing-space form is rarer but semantically the same as #1, so it
226
+ * gets the same splice-free full-stop treatment.
215
227
  * 4. Surviving dash (uncommon, e.g. at sentence start "— note") → `-`
216
228
  * so the message still renders without the AI tell.
217
229
  */
@@ -219,14 +231,27 @@ function replaceDashes(text: string): { out: string; replaced: number } {
219
231
  let replaced = 0
220
232
  let out = text
221
233
 
222
- // #1: spaced em-dash mid-prose. Decide between ", " and ". " on
223
- // the leading character of the following token.
224
- out = out.replace(/(\S) [—–] (\S)/g, (_m, before: string, after: string) => {
234
+ // Recapitalize the first ASCII-lowercase letter of `after` so the new
235
+ // sentence that a full-stop substitution creates reads correctly. A
236
+ // non-letter start (digit, `(`, quote) is left as-is. A lowercase-initial
237
+ // mixed-case brand token (iOS, eBay, iPhone, macOS — second char is
238
+ // uppercase) is ALSO left as-is: recapitalizing it to "IOS" corrupts the
239
+ // brand, and the token's own capitalization already reads as intentional.
240
+ const capFirst = (after: string): string => {
241
+ if (/^[a-z][A-Z]/.test(after)) return after
242
+ return after.replace(/^([a-z])/, (_m, ch: string) => ch.toUpperCase())
243
+ }
244
+
245
+ // #1: spaced em-dash mid-prose. A spaced dash joins two independent
246
+ // clauses far more often than it sets off an appositive, so degrade it
247
+ // to a full stop (never a comma — that produces a comma splice) and
248
+ // recapitalize the following word into a new sentence. A digit-flanked
249
+ // dash is a numeric range (10–20), not a clause break, so it's excluded
250
+ // here and falls through to rule #4 (→ ASCII hyphen, "10-20").
251
+ out = out.replace(/(\S) [—–] (\S)(\S?)/g, (m, before: string, after: string, peek: string) => {
252
+ if (/\d/.test(before) && /\d/.test(after)) return m
225
253
  replaced++
226
- // If `after` is uppercase ASCII or one of a known sentence-starter
227
- // set, treat as new sentence; otherwise a parenthetical comma.
228
- const sentenceStart = /[A-Z]/.test(after)
229
- return sentenceStart ? `${before}. ${after}` : `${before}, ${after}`
254
+ return `${before}. ${capFirst(after + peek)}`
230
255
  })
231
256
 
232
257
  // #2: dash at end of line. Treat as full stop.
@@ -235,11 +260,14 @@ function replaceDashes(text: string): { out: string; replaced: number } {
235
260
  return `.${ws}`
236
261
  })
237
262
 
238
- // #3: bare dash between word chars (no flanking spaces). Treat as
239
- // missing-space form of #1; comma is the safe fallback.
240
- out = out.replace(/(\w)[—–](\w)/g, (_m, before: string, after: string) => {
263
+ // #3: bare dash between word chars (no flanking spaces). Missing-space
264
+ // form of #1 same full-stop treatment so it can't create a splice. A
265
+ // digit-flanked dash (3–2) is a numeric range, not a clause break, so it's
266
+ // excluded and falls through to rule #4 (→ ASCII hyphen, "3-2").
267
+ out = out.replace(/(\w)[—–](\w)(\w?)/g, (m, before: string, after: string, peek: string) => {
268
+ if (/\d/.test(before) && /\d/.test(after)) return m
241
269
  replaced++
242
- return `${before}, ${after}`
270
+ return `${before}. ${capFirst(after + peek)}`
243
271
  })
244
272
 
245
273
  // #4: anything still standing — convert to ASCII hyphen so no
@@ -266,6 +294,28 @@ function replaceDashes(text: string): { out: string; replaced: number } {
266
294
  * checked per call so an operator can flip it via env var without a
267
295
  * restart of an in-process test.
268
296
  */
297
+ /**
298
+ * Dash-only normalization — the em/en-dash substitution from `scrubVoice`
299
+ * WITHOUT the opener-strip or the metric/result-object side effects.
300
+ *
301
+ * Exists so non-reply surfaces (progress cards, worker narration, PTY
302
+ * previews via card-format.ts) can close the em-dash leak on model-authored
303
+ * prose without pulling in the opener strip (which those paths must not do)
304
+ * and without duplicating the dash regexes. Reuses the exact same
305
+ * `park`/`replaceDashes`/`restore` mechanism as `scrubVoice`, so code spans
306
+ * and fenced blocks are masked identically and dashes inside them survive.
307
+ *
308
+ * Honors the same `SWITCHROOM_DISABLE_VOICE_SCRUB` kill switch. Returns the
309
+ * input unchanged when disabled, empty, or nothing changed.
310
+ */
311
+ export function normalizeDashes(text: string): string {
312
+ if (!enabled() || text.length === 0) return text
313
+ const { parked, parts } = park(text)
314
+ const { out, replaced } = replaceDashes(parked)
315
+ if (replaced === 0) return text
316
+ return restore(out, parts)
317
+ }
318
+
269
319
  export function scrubVoice(text: string): VoiceScrubResult {
270
320
  if (!enabled() || text.length === 0) {
271
321
  return { scrubbed: text, replaced: 0, openersStripped: 0 }