switchroom 0.16.38 → 0.16.47

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 (59) hide show
  1. package/dist/agent-scheduler/index.js +8 -2
  2. package/dist/auth-broker/index.js +7 -1
  3. package/dist/cli/notion-write-pretool.mjs +7 -1
  4. package/dist/cli/switchroom.js +1259 -375
  5. package/dist/cli/ui/index.html +877 -214
  6. package/dist/host-control/main.js +116 -84
  7. package/dist/vault/approvals/kernel-server.js +8 -2
  8. package/dist/vault/broker/server.js +8 -2
  9. package/examples/minimal.yaml +1 -1
  10. package/examples/switchroom.yaml +1 -1
  11. package/package.json +2 -2
  12. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  13. package/skills/switchroom-status/SKILL.md +1 -1
  14. package/telegram-plugin/bridge/bridge.ts +2 -1
  15. package/telegram-plugin/card-format.ts +7 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +20 -2
  17. package/telegram-plugin/dist/gateway/gateway.js +2197 -964
  18. package/telegram-plugin/dist/server.js +20 -2
  19. package/telegram-plugin/format.ts +305 -31
  20. package/telegram-plugin/gateway/gateway.ts +310 -70
  21. package/telegram-plugin/gateway/model-command.ts +173 -19
  22. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  23. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  24. package/telegram-plugin/package.json +1 -1
  25. package/telegram-plugin/session-tail.ts +47 -1
  26. package/telegram-plugin/stream-reply-handler.ts +19 -1
  27. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  28. package/telegram-plugin/tests/card-format.test.ts +28 -0
  29. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  30. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  31. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  32. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  33. package/telegram-plugin/tests/model-command.test.ts +213 -47
  34. package/telegram-plugin/tests/paragraph-normalizer.test.ts +203 -21
  35. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  36. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  37. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  38. package/telegram-plugin/tests/telegram-format.test.ts +33 -8
  39. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  40. package/telegram-plugin/tests/tool-activity-summary.test.ts +6 -1
  41. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  42. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  43. package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +46 -0
  44. package/telegram-plugin/tests/voice-ondemand.test.ts +99 -2
  45. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  46. package/telegram-plugin/tests/worker-activity-feed.test.ts +49 -0
  47. package/telegram-plugin/text-voice-scrub.ts +68 -18
  48. package/telegram-plugin/tool-activity-summary.ts +20 -108
  49. package/telegram-plugin/tts-normalize.ts +377 -0
  50. package/telegram-plugin/uat/driver.ts +472 -22
  51. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  52. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  53. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  55. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  56. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  57. package/telegram-plugin/voice-ondemand.ts +161 -10
  58. package/telegram-plugin/voice-presynth.ts +242 -0
  59. package/telegram-plugin/worker-activity-feed.ts +9 -1
@@ -308,6 +308,32 @@ export const MODEL_CALLBACK_REFRESH = 'mdl:r'
308
308
  export const MODEL_CALLBACK_SR = 'mdl:sr:'
309
309
  /** Callback for section-header rows — shows an informational toast, no action. */
310
310
  export const MODEL_CALLBACK_HEADER = 'mdl:h'
311
+ /**
312
+ * Callback prefix for Claude aliases that the CLI picker doesn't render but
313
+ * the CLI resolves natively (e.g. `fable`). Carries the alias verbatim; its
314
+ * handler INJECTS `/model <alias>` — the same mechanism MODEL_CALLBACK_SR
315
+ * uses — because the cursor-nav select path can only pick rows claude's own
316
+ * picker actually renders.
317
+ */
318
+ export const MODEL_CALLBACK_ALIAS = 'mdl:alias:'
319
+ /** Callback: open the nested "External models" keyboard page. */
320
+ export const MODEL_CALLBACK_PAGE_EXTERNAL = 'mdl:page:ext'
321
+ /** Callback: return from the External page to the main keyboard page. */
322
+ export const MODEL_CALLBACK_PAGE_MAIN = 'mdl:page:main'
323
+
324
+ /** Which keyboard page the model menu is currently rendering. */
325
+ export type ModelMenuPage = 'main' | 'external'
326
+
327
+ /**
328
+ * Static Claude aliases appended to the scraped Claude group. The claude CLI's
329
+ * own `/model` picker (deps.discover) does NOT list `fable`, but the CLI
330
+ * resolves the alias natively, so we render it as an extra button that selects
331
+ * by injecting `/model fable` (MODEL_CALLBACK_ALIAS). Extend this list to
332
+ * surface further CLI-resolvable aliases the picker omits.
333
+ */
334
+ export const EXTRA_CLAUDE_ALIASES: ReadonlyArray<{ alias: string; label: string }> = [
335
+ { alias: 'fable', label: 'Fable' },
336
+ ]
311
337
 
312
338
  /**
313
339
  * Friendly display names for sr-* synthetic model names. An sr-* model in
@@ -394,14 +420,42 @@ function headerRow(label: string): ModelMenuKeyboardButton[] {
394
420
  return [{ text: label, callback_data: MODEL_CALLBACK_HEADER }]
395
421
  }
396
422
 
397
- function menuKeyboard(
423
+ /**
424
+ * The external (🌐 non-Anthropic) model list, sourced from the static
425
+ * SR_MODEL_ALIASES values UNION-ed with any live discoverSrModels() results,
426
+ * deduped and sorted.
427
+ *
428
+ * Why the static union: discoverSrModels() reads LiteLLM's /model/info, which
429
+ * requires ANTHROPIC_CUSTOM_HEADERS (a litellm key) to be set on the gateway
430
+ * process. switchroom never sets that env on the gateway, so in production
431
+ * discoverSrModels() always returns [] and the external group was silently
432
+ * empty. The six SR_MODEL_ALIASES targets are the sr-* names the litellm
433
+ * config actually exposes, so seeding from them makes the group reliable
434
+ * without the missing env — while still merging any live results on hosts
435
+ * that do configure discovery.
436
+ *
437
+ * Subscription-honest: ONLY the curated sr-* aliases surface as buttons. Raw
438
+ * gpt-4o / openrouter/* dupes / voyage-* embeddings never do.
439
+ */
440
+ export function externalModelNames(discovered: string[]): string[] {
441
+ const set = new Set<string>(Object.values(SR_MODEL_ALIASES))
442
+ for (const n of discovered) {
443
+ if (isSrModel(n)) set.add(n)
444
+ }
445
+ return [...set].sort()
446
+ }
447
+
448
+ /**
449
+ * Main keyboard page: scraped Claude buttons + static Fable alias, then (only
450
+ * when the external list is non-empty) a single "🌐 External models ▸" row that
451
+ * opens the nested page, then Refresh.
452
+ */
453
+ function mainPageKeyboard(
398
454
  claudeOptions: ModelPickerOption[],
399
- srOptions: ModelPickerOption[],
455
+ hasExternal: boolean,
400
456
  ): ModelMenuKeyboardButton[][] {
401
- const hasBothGroups = claudeOptions.length > 0 && srOptions.length > 0
402
457
  const rows: ModelMenuKeyboardButton[][] = []
403
458
 
404
- if (hasBothGroups) rows.push(headerRow('── Claude (Max / Pro subscription) ──'))
405
459
  for (const o of claudeOptions) {
406
460
  rows.push([{
407
461
  text: o.current ? `✅ ${o.label}` : o.label,
@@ -409,23 +463,44 @@ function menuKeyboard(
409
463
  }])
410
464
  }
411
465
 
412
- // sr-* models are non-Anthropic (routed via LiteLLM OpenRouter).
413
- // Selection uses text-inject rather than cursor-nav more reliable
414
- // when the picker has many models (GATEWAY_MODEL_DISCOVERY=1).
415
- if (srOptions.length > 0) {
416
- rows.push(headerRow('── OpenRouter / external ──'))
417
- for (const o of srOptions) {
418
- rows.push([{
419
- text: `🌐 ${srFriendlyLabel(o.label)}`,
420
- callback_data: `${MODEL_CALLBACK_SR}${o.label}`,
421
- }])
422
- }
466
+ // Static Claude aliases the CLI picker omits (e.g. Fable). Deduped: if the
467
+ // scraped Claude options already include a matching row, don't render the
468
+ // static one too.
469
+ for (const { alias, label } of EXTRA_CLAUDE_ALIASES) {
470
+ const already = claudeOptions.some(
471
+ (o) => o.label.toLowerCase() === label.toLowerCase() ||
472
+ o.label.toLowerCase() === alias.toLowerCase(),
473
+ )
474
+ if (already) continue
475
+ rows.push([{ text: label, callback_data: `${MODEL_CALLBACK_ALIAS}${alias}` }])
476
+ }
477
+
478
+ if (hasExternal) {
479
+ rows.push([{ text: '🌐 External models ▸', callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }])
423
480
  }
424
481
 
425
482
  rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
426
483
  return rows
427
484
  }
428
485
 
486
+ /**
487
+ * External keyboard page: a labelled header, one 🌐 button per external model
488
+ * (reusing the existing MODEL_CALLBACK_SR select handler), then Back + Refresh.
489
+ */
490
+ function externalPageKeyboard(externalNames: string[]): ModelMenuKeyboardButton[][] {
491
+ const rows: ModelMenuKeyboardButton[][] = []
492
+ rows.push(headerRow('── External (billed separately) ──'))
493
+ for (const name of externalNames) {
494
+ rows.push([{
495
+ text: `🌐 ${srFriendlyLabel(name)}`,
496
+ callback_data: `${MODEL_CALLBACK_SR}${name}`,
497
+ }])
498
+ }
499
+ rows.push([{ text: '◂ Back', callback_data: MODEL_CALLBACK_PAGE_MAIN }])
500
+ rows.push([{ text: '🔄 Refresh', callback_data: MODEL_CALLBACK_REFRESH }])
501
+ return rows
502
+ }
503
+
429
504
  /**
430
505
  * Build the `/model` dashboard: live model + quota brief + tap menu.
431
506
  * Returns a keyboard-less fallback (v1-shaped static text) when the
@@ -433,6 +508,7 @@ function menuKeyboard(
433
508
  */
434
509
  export async function buildModelMenu(
435
510
  deps: ModelMenuDeps & ModelCommandDeps,
511
+ page: ModelMenuPage = 'main',
436
512
  ): Promise<ModelMenuReply> {
437
513
  if (deps.isBusy()) return busyReply(deps)
438
514
 
@@ -460,7 +536,26 @@ export async function buildModelMenu(
460
536
  // sr-* models come from LiteLLM (/model/info via discoverSrModels), not the
461
537
  // claude picker — the CLI only knows Anthropic models.
462
538
  const { claude: claudeOptions } = classifyDiscoveredOptions(discovered.options)
463
- const srOptions: ModelPickerOption[] = srNames.map((name, i) => ({ index: i, label: name, detail: '', current: false }))
539
+ const externalNames = externalModelNames(srNames)
540
+
541
+ // External page: a focused list of the 🌐 (billed-separately) models with a
542
+ // Back button. It never switches the model itself — the page callbacks just
543
+ // re-render with the other page's keyboard.
544
+ if (page === 'external') {
545
+ const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}** · 🌐 External`]
546
+ lines.push(
547
+ '',
548
+ 'These models are **billed separately** via OpenRouter — they do NOT use your Claude Max/Pro subscription. Tap one to switch the **live session**:',
549
+ PERSIST_NOTE,
550
+ )
551
+ return { text: lines.join('\n'), html: true, keyboard: externalPageKeyboard(externalNames) }
552
+ }
553
+
554
+ // claude's ✔ marks the DEFAULT FOR NEW SESSIONS, which is a different axis
555
+ // from the model the agent is running right now (set via --model at launch
556
+ // or a prior session switch). Labelling the ✔ row "Now:" was misleading —
557
+ // it could read "Opus 4.8" while the live session is on Fable. Call it what
558
+ // it is, and tell the operator a switch applies to the live session.
464
559
  const current = claudeOptions.find((o) => o.current)
465
560
  const lines: string[] = [`**Model — ${deps.escapeHtml(deps.getAgentName())}**`]
466
561
  if (discovered.dismissFailed) {
@@ -474,12 +569,16 @@ export async function buildModelMenu(
474
569
  }
475
570
  if (quota) lines.push(`Quota: ${deps.escapeHtml(quota)}`)
476
571
  lines.push('', 'Tap a model to switch the **live session**:')
477
- if (srOptions.length > 0) {
478
- lines.push('Claude models use your Max/Pro subscription. 🌐 models are billed separately via OpenRouter.')
572
+ if (externalNames.length > 0) {
573
+ lines.push('Claude models use your Max/Pro subscription. Tap 🌐 External models for models billed separately via OpenRouter.')
479
574
  }
480
575
  lines.push(PERSIST_NOTE)
481
576
 
482
- return { text: lines.join('\n'), html: true, keyboard: menuKeyboard(claudeOptions, srOptions) }
577
+ return {
578
+ text: lines.join('\n'),
579
+ html: true,
580
+ keyboard: mainPageKeyboard(claudeOptions, externalNames.length > 0),
581
+ }
483
582
  }
484
583
 
485
584
  export interface ModelCallbackOutcome {
@@ -519,6 +618,61 @@ export async function handleModelMenuCallback(
519
618
  return { answer: 'Refreshed', reply: await buildModelMenu(deps) }
520
619
  }
521
620
 
621
+ // Page navigation — these DO NOT switch the model. They just re-render the
622
+ // menu with the other page's keyboard + body text (mirrors the REFRESH shape).
623
+ if (data === MODEL_CALLBACK_PAGE_EXTERNAL) {
624
+ return { answer: 'External models', reply: await buildModelMenu(deps, 'external') }
625
+ }
626
+ if (data === MODEL_CALLBACK_PAGE_MAIN) {
627
+ return { answer: 'Back', reply: await buildModelMenu(deps, 'main') }
628
+ }
629
+
630
+ // Claude-alias tap (e.g. Fable): the CLI resolves the alias but its picker
631
+ // doesn't render it, so select by injecting `/model <alias>` — same path as
632
+ // the sr-* handler below, no cursor-nav.
633
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
634
+ const alias = data.slice(MODEL_CALLBACK_ALIAS.length)
635
+ if (!isValidModelArg(alias)) {
636
+ return { answer: 'Invalid model name', reply: await buildModelMenu(deps) }
637
+ }
638
+ if (deps.isBusy()) {
639
+ return {
640
+ answer: '⏳ Agent is mid-turn — tap again when it’s idle',
641
+ reply: busyReply(deps),
642
+ toastOnly: true,
643
+ }
644
+ }
645
+ let aliasResult: InjectResult
646
+ try {
647
+ aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`)
648
+ } catch (err) {
649
+ const msg = err instanceof Error ? err.message : String(err)
650
+ return {
651
+ answer: 'Switch failed',
652
+ reply: await menuWithBanner(deps, `❌ Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`),
653
+ }
654
+ }
655
+ if (aliasResult.outcome === 'ok') {
656
+ const confirmation =
657
+ aliasResult.output
658
+ .split('\n')
659
+ .map((l) => l.trim())
660
+ .find((l) => /set model|switched/i.test(l)) ?? `Switched to ${alias} (session)`
661
+ return {
662
+ answer: confirmation,
663
+ reply: await menuWithBannerStatic(deps, `✅ ${deps.escapeHtml(confirmation)}`),
664
+ selectedModel: sessionModelFromConfirmation(confirmation) ?? alias,
665
+ }
666
+ }
667
+ return {
668
+ answer: 'Switch failed',
669
+ reply: await menuWithBanner(
670
+ deps,
671
+ `❌ Switch to **${deps.escapeHtml(alias)}** failed — agent may be mid-turn`,
672
+ ),
673
+ }
674
+ }
675
+
522
676
  if (data === MODEL_CALLBACK_HEADER) {
523
677
  // Section-header row — the gateway handles this with a direct answerCallbackQuery
524
678
  // before calling this function, so this branch is dead in practice. Guard
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Type declarations for the PreToolUse hook module so TypeScript surfaces
3
+ * (tool-activity-summary.ts's `describeToolUse`) can delegate to
4
+ * `computeLabel` — the ONE status-vocabulary composer — without tsc
5
+ * resolution errors. The runtime module is plain ESM (hooks are executed
6
+ * directly by Claude Code, so they cannot be TS); main() is guarded behind
7
+ * an is-main check, making the import side-effect-free.
8
+ */
9
+ export function computeLabel(
10
+ toolName: string,
11
+ input: Record<string, unknown> | undefined | null,
12
+ ): string | null
@@ -76,6 +76,16 @@ function safeBasename(p) {
76
76
  }
77
77
  }
78
78
 
79
+ /** Hostname only, `www.` stripped — the WebFetch label form ("Reading example.com"). */
80
+ function urlHost(u) {
81
+ if (!u || typeof u !== 'string') return ''
82
+ try {
83
+ return new URL(u).hostname.replace(/^www\./, '')
84
+ } catch {
85
+ return u
86
+ }
87
+ }
88
+
79
89
  function urlHostPath(u) {
80
90
  if (!u || typeof u !== 'string') return ''
81
91
  try {
@@ -115,26 +125,53 @@ export function computeLabel(toolName, input) {
115
125
  }
116
126
 
117
127
  // Built-in rule table.
128
+ //
129
+ // THE single status vocabulary. This function is the ONE composer for the
130
+ // per-tool activity wording on EVERY surface: the real-time sidecar drives
131
+ // the live feed from here, and the gateway/watcher flush paths delegate via
132
+ // `describeToolUse` (tool-activity-summary.ts). Do NOT fork a second
133
+ // wording table — status-vocabulary-unification.test.ts pins the
134
+ // delegation, so drift fails CI.
118
135
  switch (toolName) {
119
- case 'Read':
120
- return `Reading ${clip(safeBasename(i.file_path))}`.trim()
136
+ case 'Read': {
137
+ const f = clip(safeBasename(i.file_path))
138
+ return f ? `Reading ${f}` : 'Reading a file'
139
+ }
121
140
  case 'Edit':
122
- return `Editing ${clip(safeBasename(i.file_path))}`.trim()
123
- case 'Write':
124
- return `Writing ${clip(safeBasename(i.file_path))}`.trim()
141
+ case 'MultiEdit': {
142
+ const f = clip(safeBasename(i.file_path))
143
+ return f ? `Editing ${f}` : 'Editing a file'
144
+ }
145
+ case 'Write': {
146
+ const f = clip(safeBasename(i.file_path))
147
+ return f ? `Writing ${f}` : 'Writing a file'
148
+ }
125
149
  case 'Grep': {
126
- const path = i.path ? clip(asText(i.path), 40) : '.'
127
150
  const pat = clip(asText(i.pattern), 40)
128
- return `Searching ${path} for ${pat}`
151
+ if (!pat) return 'Searching files'
152
+ const path = i.path ? clip(asText(i.path), 40) : ''
153
+ return path ? `Searching ${path} for ${pat}` : `Searching for ${pat}`
154
+ }
155
+ case 'Glob': {
156
+ const pat = clip(asText(i.pattern), 60)
157
+ return pat ? `Finding files matching ${pat}` : 'Searching files'
158
+ }
159
+ case 'WebFetch': {
160
+ const h = clip(urlHost(i.url), 60)
161
+ return h ? `Reading ${h}` : 'Reading a web page'
129
162
  }
130
- case 'Glob':
131
- return `Finding files matching ${clip(asText(i.pattern), 60)}`
132
- case 'WebFetch':
133
- return `Fetching ${clip(urlHostPath(i.url), 60)}`
134
- case 'WebSearch':
135
- return `Searching the web for ${clip(asText(i.query), 60)}`
136
- case 'NotebookEdit':
137
- return `Editing notebook ${clip(safeBasename(i.notebook_path))}`
163
+ case 'WebSearch': {
164
+ const q = clip(asText(i.query), 60)
165
+ return q ? `Searching the web for ${q}` : 'Searching the web'
166
+ }
167
+ case 'NotebookEdit': {
168
+ const f = clip(safeBasename(i.notebook_path))
169
+ return f ? `Editing ${f}` : 'Editing a notebook'
170
+ }
171
+ case 'TaskCreate':
172
+ case 'TaskUpdate':
173
+ case 'TaskList':
174
+ return 'Updating the plan'
138
175
  case 'BashOutput':
139
176
  return 'Reading background output'
140
177
  case 'KillBash':
@@ -183,7 +220,8 @@ export function computeLabel(toolName, input) {
183
220
  case 'mcp__hindsight__reflect':
184
221
  return 'Searching memory'
185
222
  case 'mcp__hindsight__retain':
186
- return 'Saving memory'
223
+ case 'mcp__hindsight__update_memory':
224
+ return 'Saving to memory'
187
225
  // Explicit suppressions — return null so we don't emit a sidecar line.
188
226
  case 'mcp__hindsight__sync_retain':
189
227
  return null
@@ -27,7 +27,7 @@
27
27
  "dependencies": {
28
28
  "@grammyjs/runner": "^2.0.3",
29
29
  "@modelcontextprotocol/sdk": "^1.0.0",
30
- "@mtcute/node": "^0.27.0",
30
+ "@mtcute/node": "^0.30.1",
31
31
  "@secretlint/core": "^12.2.0",
32
32
  "@secretlint/secretlint-rule-preset-recommend": "^12.2.0",
33
33
  "@secretlint/types": "^12.2.0",
@@ -239,6 +239,34 @@ export function projectAssistantTextBlocks(
239
239
  return out
240
240
  }
241
241
 
242
+ /**
243
+ * True iff this assistant message's `content` carries the "answer surface"
244
+ * — a `text` block, or a real (non-`Agent`/`Task`) `tool_use`. Used to gate
245
+ * the `stop_reason === 'end_turn'` sub-agent terminal so it never fires on a
246
+ * split-off thinking-only line (see the terminal comment in
247
+ * projectSubagentLine). A thinking-only or empty line returns false; the real
248
+ * terminal rides the following content line, which also carries `end_turn`.
249
+ */
250
+ export function assistantLineCarriesAnswerSurface(
251
+ content: Array<Record<string, unknown>> | undefined,
252
+ ): boolean {
253
+ if (!Array.isArray(content)) return false
254
+ for (const c of content) {
255
+ const ct = (c?.type as string | undefined) ?? ''
256
+ if (ct === 'text') {
257
+ // A non-empty text block is the answer surface.
258
+ const t = c.text as string | undefined
259
+ if (typeof t === 'string' && t.trim().length > 0) return true
260
+ } else if (ct === 'tool_use') {
261
+ // A real tool_use is content too. (An end_turn message rarely contains a
262
+ // tool_use, but if it does it is content-final, not a bare thinking split.)
263
+ const name = (c.name as string | undefined) ?? ''
264
+ if (name !== 'Agent' && name !== 'Task') return true
265
+ }
266
+ }
267
+ return false
268
+ }
269
+
242
270
  /**
243
271
  * Project a single transcript line into a SessionEvent (or null if it's
244
272
  * uninteresting noise). Caller is responsible for the JSON parse — if a
@@ -486,8 +514,26 @@ export function projectSubagentLine(
486
514
  // events so the final text/preamble still renders; the watcher's turn_end
487
515
  // handler is guarded on `state === 'running'`, so a later real
488
516
  // turn_duration line is a no-op.
517
+ //
518
+ // UPSTREAM-SHAPE HARDENING (Claude Code ≥2.1.x): one logical assistant
519
+ // message is now persisted as MULTIPLE JSONL lines sharing one
520
+ // `message.id`, one content-block per line, and the terminal `stop_reason`
521
+ // (`end_turn`) is stamped on EVERY split line — including the leading
522
+ // `[thinking]` line that precedes the `[text: final answer]` line. Firing
523
+ // the terminal on the thinking-only line marks the sub-agent `done` and
524
+ // hands back stale/empty text BEFORE the real handback `[text]` line is
525
+ // projected (the watcher's onProgress is `state==='running'`-gated, so the
526
+ // late text is dropped). Guard: only treat `end_turn` as terminal on a line
527
+ // that actually carries the message's answer surface (a `text` block, or a
528
+ // non-`Agent`/`Task` tool_use). A thinking-only `end_turn` line is a split
529
+ // preamble; its terminal + handback ride the following content line, which
530
+ // still carries `end_turn` and fires correctly AFTER the text event. The
531
+ // old single-line `[thinking, text](end_turn)` shape has a `text` block, so
532
+ // it fires exactly as before — graceful degradation on both shapes. A
533
+ // genuine thinking-only end with no answer still terminates via the
534
+ // `turn_duration` / capped-reaper / watcher stall nets.
489
535
  const stopReason = message?.stop_reason as string | undefined
490
- if (stopReason === 'end_turn') {
536
+ if (stopReason === 'end_turn' && assistantLineCarriesAnswerSurface(content)) {
491
537
  events.push({ kind: 'sub_agent_turn_end', agentId })
492
538
  }
493
539
  return events
@@ -158,6 +158,19 @@ export interface StreamReplyDeps {
158
158
  * the raw (repaired) text is sent unchanged.
159
159
  */
160
160
  normalizeParagraphBreaks?: (text: string) => string
161
+ /**
162
+ * Punctuation/bullet normalization (fleet-wide consistent formatting):
163
+ * em/en dashes → comma/hyphen, leading unicode bullets → `- `. Applied on
164
+ * code-masked text right after normalizeParagraphBreaks. Optional for
165
+ * backward compat; omitted → no normalization.
166
+ */
167
+ normalizePunctuation?: (text: string) => string
168
+ /**
169
+ * Over-bold tripwire: strips `**bold**` markers when a message is clearly
170
+ * over-bolded (>30% bold, or whole paragraphs/lists fully bolded). Applied
171
+ * after normalizePunctuation. Optional for backward compat.
172
+ */
173
+ stripExcessBold?: (text: string) => string
161
174
  /**
162
175
  * Insert a visible blank-line spacer into each prose `\n\n` gap so the rich
163
176
  * GFM renderer shows a real empty line between paragraphs (the rich engine
@@ -316,9 +329,14 @@ export async function handleStreamReply(
316
329
  deps: StreamReplyDeps,
317
330
  ): Promise<StreamReplyResult> {
318
331
  const chat_id = args.chat_id
319
- const rawText = deps.normalizeParagraphBreaks
332
+ let rawText = deps.normalizeParagraphBreaks
320
333
  ? deps.normalizeParagraphBreaks(deps.repairEscapedWhitespace(args.text))
321
334
  : deps.repairEscapedWhitespace(args.text)
335
+ // Fleet-wide consistent formatting: dash/bullet normalization + over-bold
336
+ // tripwire, same order as the reply/edit paths (after paragraph
337
+ // normalization, before spacers). Both run on code-masked text internally.
338
+ if (deps.normalizePunctuation) rawText = deps.normalizePunctuation(rawText)
339
+ if (deps.stripExcessBold) rawText = deps.stripExcessBold(rawText)
322
340
  const done = Boolean(args.done)
323
341
  const format = args.format ?? deps.defaultFormat
324
342
  if (done) {
@@ -121,8 +121,40 @@ describe('scope-commit — durable hostd persistence', () => {
121
121
  expect(commitBlock).toContain('readFileSync(')
122
122
  })
123
123
 
124
- it('passes a long timeout to tryHostdDispatch (apply+reconcile blocks)', () => {
125
- expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 60_000)')
124
+ it('passes a 12-min timeout to tryHostdDispatch (apply+reconcile can take 5-10 min)', () => {
125
+ expect(commitBlock).toContain('await tryHostdDispatch(agentName, req, 720_000)')
126
+ })
127
+
128
+ it('acks the tap BEFORE the hostd await (interim status, background persist)', () => {
129
+ const interimAckIdx = commitBlock.indexOf('saving durably in background')
130
+ const bgIdx = commitBlock.indexOf('void (async () => {')
131
+ const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
132
+ expect(interimAckIdx).toBeGreaterThan(-1)
133
+ expect(bgIdx).toBeGreaterThan(-1)
134
+ expect(hostdAwaitIdx).toBeGreaterThan(-1)
135
+ // interim ack fires before the background continuation opens, and the
136
+ // slow hostd await lives INSIDE the background continuation.
137
+ expect(interimAckIdx).toBeLessThan(bgIdx)
138
+ expect(bgIdx).toBeLessThan(hostdAwaitIdx)
139
+ })
140
+
141
+ it('background continuation body is wrapped in try/catch (a throw must not become an unhandledRejection → shutdown)', () => {
142
+ const bgIdx = commitBlock.indexOf('void (async () => {')
143
+ const tryIdx = commitBlock.indexOf('try {', bgIdx)
144
+ const hostdAwaitIdx = commitBlock.indexOf('await tryHostdDispatch(')
145
+ expect(tryIdx).toBeGreaterThan(bgIdx)
146
+ // The top-level try opens before any work (including the hostd await
147
+ // and scheduleGrantRestart's sync fs writes) runs inside the IIFE.
148
+ expect(tryIdx).toBeLessThan(hostdAwaitIdx)
149
+ expect(commitBlock).toContain('always-allow background persist threw')
150
+ })
151
+
152
+ it('edits the card with the real outcome after the background persist', () => {
153
+ const bgIdx = commitBlock.indexOf('void (async () => {')
154
+ const outcomeEditIdx = commitBlock.indexOf('await ctx.editMessageText(', bgIdx)
155
+ expect(outcomeEditIdx).toBeGreaterThan(bgIdx)
156
+ // Outcome edit failure is logged, never thrown into the void continuation.
157
+ expect(commitBlock).toContain('always-allow outcome card edit failed')
126
158
  })
127
159
 
128
160
  it('registers + cleans up the single-tap correlation entry', () => {
@@ -56,6 +56,21 @@ describe('stripMarkdown', () => {
56
56
  it('does not touch HTML-significant characters (escaping stays separate)', () => {
57
57
  expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
58
58
  })
59
+
60
+ it('F1: normalizes an em-dash on this card/narration prose surface', () => {
61
+ // The reply path scrubs em-dashes but cards/narration/PTY previews did not,
62
+ // so em-dashes leaked there. stripMarkdown now runs the same dash logic.
63
+ // A clause-joining dash becomes a full stop (never a comma — that produces
64
+ // a comma splice), with the following word recapitalized into a sentence.
65
+ expect(stripMarkdown('build done — shipping now')).toBe('build done. Shipping now')
66
+ expect(stripMarkdown('build done — Shipping now')).toBe('build done. Shipping now')
67
+ })
68
+
69
+ it('F1: preserves an em-dash inside an inline code span', () => {
70
+ // The dash normalizer masks code spans, and it runs BEFORE the backticks
71
+ // are stripped, so a dash inside `` `…` `` survives verbatim.
72
+ expect(stripMarkdown('see `a — b` token')).toBe('see a — b token')
73
+ })
59
74
  })
60
75
 
61
76
  describe('cleanWorkerResultParagraph', () => {
@@ -76,6 +91,19 @@ describe('cleanWorkerResultParagraph', () => {
76
91
  it('returns empty for whitespace/markup-only input', () => {
77
92
  expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
78
93
  })
94
+
95
+ it('F1: normalizes em-dashes in worker narration prose', () => {
96
+ const input = '## Result\n\nTests pass — ready to merge'
97
+ expect(cleanWorkerResultParagraph(input)).toBe('Result Tests pass. Ready to merge')
98
+ })
99
+
100
+ it('F1: an em-dash inside a fenced block is preserved (the block is dropped, never normalized)', () => {
101
+ // Fenced blocks are excised whole, so a dash inside one is preserved by
102
+ // removal — it never reaches (nor is mutated by) the dash normalizer, and
103
+ // the surrounding prose dash IS normalized.
104
+ const input = 'before — after\n```\nlet a = b — c\n```\ntail'
105
+ expect(cleanWorkerResultParagraph(input)).toBe('before. After tail')
106
+ })
79
107
  })
80
108
 
81
109
  describe('formatDuration', () => {