switchroom 0.16.23 → 0.16.24

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 (127) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  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 +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +671 -430
  9. package/dist/host-control/main.js +156 -156
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +5 -5
  13. package/telegram-plugin/answer-stream.ts +13 -43
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/card-format.ts +28 -25
  17. package/telegram-plugin/credits-watch.ts +5 -10
  18. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  19. package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
  20. package/telegram-plugin/dist/server.js +160 -160
  21. package/telegram-plugin/draft-stream.ts +4 -4
  22. package/telegram-plugin/format.ts +101 -662
  23. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  24. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  25. package/telegram-plugin/gateway/approval-card.ts +16 -6
  26. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  27. package/telegram-plugin/gateway/auth-command.ts +74 -74
  28. package/telegram-plugin/gateway/auth-line.ts +5 -15
  29. package/telegram-plugin/gateway/boot-card.ts +20 -22
  30. package/telegram-plugin/gateway/boot-version.ts +3 -2
  31. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  32. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  33. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  34. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  35. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  36. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  37. package/telegram-plugin/gateway/effort-command.ts +17 -17
  38. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  39. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  40. package/telegram-plugin/gateway/gateway.ts +751 -624
  41. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  42. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  43. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  44. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  45. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  46. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  47. package/telegram-plugin/gateway/model-command.ts +25 -25
  48. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  49. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  50. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  51. package/telegram-plugin/issues-card.ts +6 -7
  52. package/telegram-plugin/model-unavailable.ts +8 -12
  53. package/telegram-plugin/operator-events-history.ts +1 -1
  54. package/telegram-plugin/operator-events.ts +24 -28
  55. package/telegram-plugin/package.json +1 -1
  56. package/telegram-plugin/pending-work-progress.ts +36 -36
  57. package/telegram-plugin/permission-title.ts +36 -20
  58. package/telegram-plugin/pty-partial-handler.ts +5 -13
  59. package/telegram-plugin/quota-check.ts +5 -5
  60. package/telegram-plugin/quota-watch.ts +13 -18
  61. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  62. package/telegram-plugin/registry/turns-schema.ts +43 -3
  63. package/telegram-plugin/retry-api-call.ts +15 -7
  64. package/telegram-plugin/rich-send.ts +57 -0
  65. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  66. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  67. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  68. package/telegram-plugin/silence-poke.ts +26 -69
  69. package/telegram-plugin/slot-banner-driver.ts +9 -6
  70. package/telegram-plugin/slot-banner.ts +5 -8
  71. package/telegram-plugin/steering.ts +0 -4
  72. package/telegram-plugin/stream-controller.ts +59 -62
  73. package/telegram-plugin/stream-reply-handler.ts +40 -97
  74. package/telegram-plugin/subagent-watcher.ts +2 -2
  75. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  76. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  77. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  78. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  79. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  80. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  81. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  82. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  83. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  84. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  85. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  86. package/telegram-plugin/tests/card-format.test.ts +6 -4
  87. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  88. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  89. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  90. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  91. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  92. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  93. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  94. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  95. package/telegram-plugin/tests/model-command.test.ts +2 -2
  96. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  97. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  98. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  99. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  100. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  101. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  102. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  103. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  104. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  105. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  106. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  107. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  108. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  109. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  110. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  111. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  112. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  113. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  114. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  115. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  116. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  117. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  118. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  119. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  120. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  121. package/telegram-plugin/text-voice-scrub.ts +8 -11
  122. package/telegram-plugin/tool-activity-summary.ts +29 -29
  123. package/telegram-plugin/welcome-text.ts +82 -83
  124. package/telegram-plugin/worker-activity-feed.ts +2 -3
  125. package/telegram-plugin/html-sanitize.ts +0 -244
  126. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  127. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -128,7 +128,7 @@ describe('formatPermissionCardBody', () => {
128
128
  agentName: 'gymbro',
129
129
  })
130
130
  expect(body).toBe(
131
- ['🔐 <b>Gymbro</b> wants to edit: supplement-log.md', 'why: <i>logging today\'s lifts</i>'].join('\n'),
131
+ ['🔐 **Gymbro** wants to edit: supplement-log.md', 'why: _logging today\'s lifts_'].join('\n'),
132
132
  )
133
133
  })
134
134
 
@@ -141,7 +141,7 @@ describe('formatPermissionCardBody', () => {
141
141
  description: 'Restart an agent via the host-control daemon. cross-agent (`name` ≠ $SWITCHROOM_AGENT_NAME) …',
142
142
  agentName: 'carrie',
143
143
  })
144
- expect(body).toContain('why: <i>gateway is wedged, bouncing it</i>')
144
+ expect(body).toContain('why: _gateway is wedged, bouncing it_')
145
145
  expect(body).not.toContain('$SWITCHROOM_AGENT_NAME')
146
146
  expect(body).not.toContain('host-control daemon')
147
147
  })
@@ -153,7 +153,7 @@ describe('formatPermissionCardBody', () => {
153
153
  description: 'Run a shell command.',
154
154
  agentName: 'gymbro',
155
155
  })
156
- expect(body).toContain('why: <i>listing temp files</i>')
156
+ expect(body).toContain('why: _listing temp files_')
157
157
  })
158
158
 
159
159
  test('shows "not provided" when no caller reason is present (never the description)', () => {
@@ -163,7 +163,7 @@ describe('formatPermissionCardBody', () => {
163
163
  description: 'Run a shell command on the host.',
164
164
  agentName: 'gymbro',
165
165
  })
166
- expect(body).toContain('why: <i>not provided</i>')
166
+ expect(body).toContain('why: _not provided_')
167
167
  expect(body).not.toContain('Run a shell command')
168
168
  })
169
169
 
@@ -174,7 +174,7 @@ describe('formatPermissionCardBody', () => {
174
174
  description: 'Run a shell command.',
175
175
  agentName: 'gymbro',
176
176
  })
177
- expect(body).toContain('why: <i>not provided</i>')
177
+ expect(body).toContain('why: _not provided_')
178
178
  })
179
179
 
180
180
  test('drops the agent prefix when agentName is null (early-boot edge)', () => {
@@ -184,21 +184,23 @@ describe('formatPermissionCardBody', () => {
184
184
  description: 'Use a skill.',
185
185
  agentName: null,
186
186
  })
187
- expect(body).toBe(['🔐 Use the mail skill', 'why: <i>do the thing</i>'].join('\n'))
187
+ expect(body).toBe(['🔐 Use the mail skill', 'why: _do the thing_'].join('\n'))
188
188
  })
189
189
 
190
- test('HTML-escapes <, >, & in agentName / action / reason', () => {
190
+ test('markdown-escapes emphasis specials in agentName / action / reason; passes < > & through (#2669)', () => {
191
191
  const body = formatPermissionCardBody({
192
192
  toolName: 'Bash',
193
- inputPreview: JSON.stringify({ command: 'echo "a < b && c > d"', reason: 'compare a < b & c > d' }),
193
+ inputPreview: JSON.stringify({ command: 'echo "a_b *c*"', reason: 'compare a_b and c < d' }),
194
194
  description: 'Run a shell command.',
195
- agentName: 'agent<test>',
195
+ agentName: 'agent_test',
196
196
  })
197
- expect(body).toContain('&lt;test&gt;')
198
- expect(body).toContain('&amp;')
199
- expect(body).not.toContain('<test>')
200
- expect(body).toContain('<b>')
201
- expect(body).toContain('<i>')
197
+ // < > & are literal in rich markdown; the underscore in the agent name is
198
+ // backslash-escaped so it can't open an italic run.
199
+ expect(body).toContain('Agent\\_test')
200
+ expect(body).toContain('c < d')
201
+ // The card's own wrappers (** for the name, _ for the why line) are present.
202
+ expect(body).toContain('**')
203
+ expect(body).toContain('why: _')
202
204
  })
203
205
 
204
206
  test('truncates a very long caller reason with an ellipsis', () => {
@@ -208,8 +210,8 @@ describe('formatPermissionCardBody', () => {
208
210
  description: 'Use a skill.',
209
211
  agentName: 'clerk',
210
212
  })
211
- expect(body).toContain('xxxx…</i>')
212
- expect(body.split('\n')[0]).toBe('🔐 <b>Clerk</b> wants to use the mail skill')
213
+ expect(body).toContain('xxxx…_')
214
+ expect(body.split('\n')[0]).toBe('🔐 **Clerk** wants to use the mail skill')
213
215
  })
214
216
 
215
217
  test('collapses internal whitespace in the caller reason', () => {
@@ -219,7 +221,7 @@ describe('formatPermissionCardBody', () => {
219
221
  description: 'Use a skill.',
220
222
  agentName: 'clerk',
221
223
  })
222
- expect(body).toContain('why: <i>first second paragraph</i>')
224
+ expect(body).toContain('why: _first second paragraph_')
223
225
  })
224
226
 
225
227
  // config-edit-hardening: upstream Claude Code truncates `inputPreview`
@@ -247,7 +249,7 @@ describe('formatPermissionCardBody', () => {
247
249
  description: 'Propose a unified-diff patch against switchroom.yaml.',
248
250
  agentName: 'klanker',
249
251
  })
250
- expect(body).toContain(`why: <i>${reason}</i>`)
252
+ expect(body).toContain(`why: _${reason}_`)
251
253
  expect(body).not.toContain('not provided')
252
254
  })
253
255
 
@@ -263,7 +265,7 @@ describe('formatPermissionCardBody', () => {
263
265
  description: 'desc',
264
266
  agentName: 'klanker',
265
267
  })
266
- expect(body).toContain(`why: <i>${reason}</i>`)
268
+ expect(body).toContain(`why: _${reason}_`)
267
269
  })
268
270
 
269
271
  // #2469: hostd agent_* cards must name WHICH agent is targeted, pulled
@@ -275,7 +277,7 @@ describe('formatPermissionCardBody', () => {
275
277
  description: 'Restart an agent via the host-control daemon. $SWITCHROOM_AGENT_NAME …',
276
278
  agentName: 'klanker',
277
279
  })
278
- expect(body.split('\n')[0]).toBe('🔐 <b>Klanker</b> wants to restart agent `carrie` in the fleet')
280
+ expect(body.split('\n')[0]).toBe('🔐 **Klanker** wants to restart agent `carrie` in the fleet')
279
281
  })
280
282
 
281
283
  test('hostd start/stop/logs/exec each name the target agent (#2469)', () => {
@@ -286,10 +288,10 @@ describe('formatPermissionCardBody', () => {
286
288
  description: 'static schema doc',
287
289
  agentName: 'klanker',
288
290
  }).split('\n')[0]
289
- expect(mk('mcp__hostd__agent_start')).toBe('🔐 <b>Klanker</b> wants to start agent `pixel` in the fleet')
290
- expect(mk('mcp__hostd__agent_stop')).toBe('🔐 <b>Klanker</b> wants to stop agent `pixel` in the fleet')
291
- expect(mk('mcp__hostd__agent_logs')).toBe("🔐 <b>Klanker</b> wants to read agent `pixel`'s container logs")
292
- expect(mk('mcp__hostd__agent_exec')).toBe('🔐 <b>Klanker</b> wants to run a read-only inspection inside agent `pixel`')
291
+ expect(mk('mcp__hostd__agent_start')).toBe('🔐 **Klanker** wants to start agent `pixel` in the fleet')
292
+ expect(mk('mcp__hostd__agent_stop')).toBe('🔐 **Klanker** wants to stop agent `pixel` in the fleet')
293
+ expect(mk('mcp__hostd__agent_logs')).toBe("🔐 **Klanker** wants to read agent `pixel`'s container logs")
294
+ expect(mk('mcp__hostd__agent_exec')).toBe('🔐 **Klanker** wants to run a read-only inspection inside agent `pixel`')
293
295
  })
294
296
 
295
297
  test('hostd agent verb without a name arg falls back to the generic phrase (no crash) (#2469)', () => {
@@ -299,8 +301,8 @@ describe('formatPermissionCardBody', () => {
299
301
  description: 'static schema doc',
300
302
  agentName: 'klanker',
301
303
  })
302
- expect(body.split('\n')[0]).toBe('🔐 <b>Klanker</b> wants to restart an agent in the fleet')
303
- expect(body).toContain('why: <i>bouncing the fleet</i>')
304
+ expect(body.split('\n')[0]).toBe('🔐 **Klanker** wants to restart an agent in the fleet')
305
+ expect(body).toContain('why: _bouncing the fleet_')
304
306
  })
305
307
 
306
308
  test('non-name-arg gated verb (update_apply) stays generic and does not break (#2469)', () => {
@@ -310,8 +312,8 @@ describe('formatPermissionCardBody', () => {
310
312
  description: 'static schema doc',
311
313
  agentName: 'klanker',
312
314
  })
313
- expect(body.split('\n')[0]).toBe('🔐 <b>Klanker</b> wants to apply a fleet-wide update (pull + recreate)')
314
- expect(body).toContain('why: <i>rolling out v0.16</i>')
315
+ expect(body.split('\n')[0]).toBe('🔐 **Klanker** wants to apply a fleet-wide update (pull + recreate)')
316
+ expect(body).toContain('why: _rolling out v0.16_')
315
317
  })
316
318
 
317
319
  // Clarity fix: the card gains a third "↳" line summarizing the REST
@@ -330,8 +332,8 @@ describe('formatPermissionCardBody', () => {
330
332
  agentName: 'marko',
331
333
  })
332
334
  const lines = body.split('\n')
333
- expect(lines[0]).toBe('🔐 <b>Marko</b> wants to POST /smtp/email (Brevo)')
334
- expect(lines[1]).toBe('why: <i>sending the priority-access invite</i>')
335
+ expect(lines[0]).toBe('🔐 **Marko** wants to POST /smtp/email (Brevo)')
336
+ expect(lines[1]).toBe('why: _sending the priority-access invite_')
335
337
  // Third line: scalar keys show value; the nested `to` array shows key-only.
336
338
  expect(lines[2]).toContain('↳')
337
339
  expect(lines[2]).toContain('subject: Priority access')
@@ -411,7 +413,7 @@ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
411
413
  behavior: 'allow',
412
414
  action: 'edit: supplement-log.md',
413
415
  }),
414
- ).toBe('▶️ <b>Gymbro</b> — got it, continuing: <i>edit: supplement-log.md</i>')
416
+ ).toBe('▶️ **Gymbro** — got it, continuing: _edit: supplement-log.md_')
415
417
  })
416
418
 
417
419
  test('deny names what it will skip, lower-cased inline', () => {
@@ -421,7 +423,7 @@ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
421
423
  behavior: 'deny',
422
424
  action: 'Search the web',
423
425
  }),
424
- ).toBe("🚫 <b>Ziggy</b> — noted, I won't search the web. Continuing without it.")
426
+ ).toBe("🚫 **Ziggy** — noted, I won't search the web. Continuing without it.")
425
427
  })
426
428
 
427
429
  test('TTL auto-deny variant reads as a timeout, not a tap', () => {
@@ -432,17 +434,19 @@ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
432
434
  action: 'run: deploy.sh',
433
435
  timeoutMinutes: 5,
434
436
  }),
435
- ).toBe('🚫 <b>Finn</b> — no answer in 5m, continuing without it (<i>run: deploy.sh</i>).')
437
+ ).toBe('🚫 **Finn** — no answer in 5m, continuing without it (_run: deploy.sh_).')
436
438
  })
437
439
 
438
- test('HTML-escapes a hostile action phrase (no raw </>& injection)', () => {
440
+ test('markdown-escapes a hostile action phrase (no raw ** emphasis injection) (#2669)', () => {
439
441
  const out = formatPermissionResumeMessage({
440
442
  agentName: 'clerk',
441
443
  behavior: 'allow',
442
- action: 'run: echo <b>&"pwned"</b>',
444
+ action: 'run: echo **pwned**',
443
445
  })
444
- expect(out).toContain('&lt;b&gt;&amp;')
445
- expect(out).not.toContain('<b>&"pwned"')
446
+ // The injected ** markers are backslash-escaped so they render literally
447
+ // and cannot hijack the card's own emphasis.
448
+ expect(out).toContain('\\*\\*pwned\\*\\*')
449
+ expect(out).not.toContain('echo **pwned**')
446
450
  })
447
451
 
448
452
  test('cap-first on the agent name', () => {
@@ -451,22 +455,22 @@ describe('formatPermissionResumeMessage — agent-voiced verdict ack', () => {
451
455
  behavior: 'allow',
452
456
  action: 'read: contract.pdf',
453
457
  })
454
- expect(out).toContain('<b>Lawgpt</b>')
458
+ expect(out).toContain('**Lawgpt**')
455
459
  })
456
460
 
457
461
  test('empty action falls back to a generic continue (no dangling phrase)', () => {
458
462
  expect(
459
463
  formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'allow', action: '' }),
460
- ).toBe('▶️ <b>Carrie</b> — got it, back to work.')
464
+ ).toBe('▶️ **Carrie** — got it, back to work.')
461
465
  expect(
462
466
  formatPermissionResumeMessage({ agentName: 'carrie', behavior: 'deny', action: ' ' }),
463
- ).toBe('🚫 <b>Carrie</b> — noted, continuing without it.')
467
+ ).toBe('🚫 **Carrie** — noted, continuing without it.')
464
468
  })
465
469
 
466
470
  test('null agent name degrades to a neutral "Agent" label', () => {
467
471
  expect(
468
472
  formatPermissionResumeMessage({ agentName: null, behavior: 'allow', action: 'edit: x.md' }),
469
- ).toBe('▶️ <b>Agent</b> — got it, continuing: <i>edit: x.md</i>')
473
+ ).toBe('▶️ **Agent** — got it, continuing: _edit: x.md_')
470
474
  })
471
475
  })
472
476
 
@@ -33,7 +33,6 @@ function makeDeps(
33
33
  ): PtyHandlerDeps {
34
34
  return {
35
35
  bot,
36
- renderText: (t) => t, // identity for tests — easier to assert
37
36
  ...overrides,
38
37
  }
39
38
  }
@@ -93,17 +92,18 @@ describe('handlePtyPartialPure', () => {
93
92
  expect(onFirstPartial).toHaveBeenCalledWith('1', 5)
94
93
  })
95
94
 
96
- it('applies renderText before pushing into the stream', async () => {
95
+ it('sends the raw PTY text LITERALLY (literalText path, no markdown render) (#2669)', () => {
96
+ // PTY-tail previews are raw terminal/TUI output — they go through the
97
+ // literal plain `sendMessage` path, NOT the rich-markdown parser, so
98
+ // control glyphs / box-drawing chars aren't misinterpreted as markdown.
97
99
  const state = makeState({ currentSessionChatId: '1' })
98
- const deps = makeDeps(bot, {
99
- renderText: (t) => `<b>${t}</b>`,
100
- })
100
+ const deps = makeDeps(bot)
101
101
 
102
- handlePtyPartialPure('bold', state, deps)
103
- await microtaskFlush()
102
+ handlePtyPartialPure('│ tool: ls -la │ *not bold*', state, deps)
104
103
 
105
104
  expect(bot.api.sendMessage).toHaveBeenCalledTimes(1)
106
- expect(bot.api.sendMessage.mock.calls[0][1]).toBe('<b>bold</b>')
105
+ expect(bot.api.sendRichMessage).not.toHaveBeenCalled()
106
+ expect(bot.api.sendMessage.mock.calls[0][1]).toBe('│ tool: ls -la │ *not bold*')
107
107
  })
108
108
 
109
109
  it('reuses an existing stream on second update', async () => {
@@ -91,9 +91,9 @@ describe('formatQuotaBlock', () => {
91
91
  },
92
92
  now,
93
93
  )
94
- expect(block).toContain('<b>Claude plan quota</b>')
95
- expect(block).toContain('<b>5h window</b> 29% · resets in 2h 30m')
96
- expect(block).toContain('<b>7d window</b> 33% · resets in 3d')
94
+ expect(block).toContain('**Claude plan quota**')
95
+ expect(block).toContain('**5h window** 29% · resets in 2h 30m')
96
+ expect(block).toContain('**7d window** 33% · resets in 3d')
97
97
  expect(block).toContain('Binding window: five hour')
98
98
  // overage=allowed should not be surfaced
99
99
  expect(block).not.toContain('Overage:')
@@ -249,17 +249,21 @@ describe("evaluateQuotaWatchAccount — message content", () => {
249
249
  expect(d.message).toContain("30%");
250
250
  });
251
251
 
252
- it("throttling message HTML-escapes account label", () => {
252
+ it("throttling message markdown-escapes the account label (#2669)", () => {
253
+ // The label is interpolated inside a code span — the renderer escapes
254
+ // inline-markdown specials (\ ` * _ ~ = [ ] |), not HTML entities. A label
255
+ // with an emphasis special (e.g. an underscore) is backslash-escaped; the
256
+ // literal `<` / `>` pass through (they are not markdown specials).
253
257
  const d = evaluateQuotaWatchAccount({
254
258
  agentName: "lawgpt",
255
- snap: makeSnap("<evil>@example.com", makeQuota(85, 40)),
259
+ snap: makeSnap("ev_il@example.com", makeQuota(85, 40)),
256
260
  prev: PREV_NEVER_NOTIFIED,
257
261
  now: NOW,
258
262
  });
259
263
  expect(d.kind).toBe("notify");
260
264
  if (d.kind !== "notify") return;
261
- expect(d.message).toContain("&lt;evil&gt;");
262
- expect(d.message).not.toContain("<evil>");
265
+ // The underscore is escaped so it can't open an italic run inside the span.
266
+ expect(d.message).toContain("ev\\_il@example.com");
263
267
  });
264
268
 
265
269
  it("throttling message for active account mentions /auth use", () => {
@@ -126,8 +126,9 @@ describe("secret-detect — deleteSensitiveMessage helper retains its 'surface f
126
126
  it("helper posts an in-chat warning naming the leaked message id", () => {
127
127
  // The warning is the only signal a mobile-only operator gets —
128
128
  // stderr is invisible to them. Pinning the in-chat surface as
129
- // the load-bearing piece.
130
- expect(helperBody).toMatch(/sendMessage/);
131
- expect(helperBody).toMatch(/delete message.*manually|delete it manually|manually|delete message <code>/i);
129
+ // the load-bearing piece. Post-#2669 the warning ships via the
130
+ // rich-message path (sendRichMessage).
131
+ expect(helperBody).toMatch(/sendRichMessage|sendMessage/);
132
+ expect(helperBody).toMatch(/delete message.*manually|delete it manually|manually/i);
132
133
  });
133
134
  });
@@ -251,80 +251,52 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
251
251
  expect(ctx.inFlightTools[0]!.durationMs).toBe(305_000 - 30_000)
252
252
  })
253
253
 
254
- it('formatFrameworkFallbackText names the longest-running tool with duration', () => {
255
- const text = formatFrameworkFallbackText('working', 305_000, [
256
- { name: 'Grep', label: '"foo"', durationMs: 275_000 },
257
- ])
258
- expect(text).toBe(
259
- 'running Grep "foo" for 5m (no update from agent in 5 min)',
260
- )
261
- })
262
-
263
- it('multiple in-flight tools render as "+ N more"', () => {
264
- const text = formatFrameworkFallbackText('working', 305_000, [
265
- { name: 'Grep', label: '"foo"', durationMs: 275_000 },
266
- { name: 'Read', label: 'config.ts', durationMs: 120_000 },
267
- { name: 'Bash', label: null, durationMs: 60_000 },
268
- ])
269
- expect(text).toBe(
270
- 'running Grep "foo" + 2 more for 5m (no update from agent in 5 min)',
271
- )
272
- })
273
-
274
- it('tool with no label renders the bare name', () => {
275
- const text = formatFrameworkFallbackText('working', 305_000, [
276
- { name: 'Bash', label: null, durationMs: 305_000 },
277
- ])
278
- expect(text).toBe(
279
- 'running Bash for 5m (no update from agent in 5 min)',
280
- )
281
- })
282
-
283
- it('raw mcp__ tool name with a human label drops the technical name and leads with the label', () => {
284
- // mcp__hindsight__reflect is the internal MCP identifier — looks
285
- // like a leak when surfaced to a user. The label table emits
286
- // "Searching memory" for it (see hooks/tool-label-pretool.mjs);
287
- // the fallback message should lead with the label, not concatenate
288
- // both.
289
- const text = formatFrameworkFallbackText('working', 305_000, [
290
- { name: 'mcp__hindsight__reflect', label: 'Searching memory', durationMs: 305_000 },
291
- ])
292
- expect(text).toBe(
293
- 'Searching memory for 5m (no update from agent in 5 min)',
294
- )
295
- })
296
-
297
- it('raw mcp__ tool name with NO label falls back to the bare name (no leak-but-no-better-option)', () => {
298
- const text = formatFrameworkFallbackText('working', 305_000, [
299
- { name: 'mcp__some-third-party__do_thing', label: null, durationMs: 305_000 },
300
- ])
301
- expect(text).toBe(
302
- 'running mcp__some-third-party__do_thing for 5m (no update from agent in 5 min)',
303
- )
254
+ // The "no update from agent in N min" stall notice is a retired stop-gap
255
+ // (operator: obsolete now that the live draft + the model's own pacing beats
256
+ // carry progress). `formatFrameworkFallbackText` now returns null for every
257
+ // stall variant — the generic "still working / still thinking" notice AND the
258
+ // #1292 tool-aware enrichment — so the gateway sends nothing for a plain
259
+ // stall (it still runs the unwedge teardown). Only `blockedOnApproval`
260
+ // still produces a user-visible string. The tests below pin that contract.
261
+
262
+ it('stall notice (in-flight tool) returns null — no user-visible send', () => {
263
+ expect(
264
+ formatFrameworkFallbackText('working', 305_000, [
265
+ { name: 'Grep', label: '"foo"', durationMs: 275_000 },
266
+ ]),
267
+ ).toBeNull()
304
268
  })
305
269
 
306
- it('built-in tool (Grep) with a label keeps the prior "running Name label" shape — name is already human-readable', () => {
307
- const text = formatFrameworkFallbackText('working', 305_000, [
308
- { name: 'Grep', label: 'foo', durationMs: 305_000 },
309
- ])
310
- expect(text).toBe(
311
- 'running Grep foo for 5m (no update from agent in 5 min)',
312
- )
270
+ it('stall notice (multiple in-flight tools) returns null', () => {
271
+ expect(
272
+ formatFrameworkFallbackText('working', 305_000, [
273
+ { name: 'Grep', label: '"foo"', durationMs: 275_000 },
274
+ { name: 'Read', label: 'config.ts', durationMs: 120_000 },
275
+ { name: 'Bash', label: null, durationMs: 60_000 },
276
+ ]),
277
+ ).toBeNull()
313
278
  })
314
279
 
315
- it('empty inFlightTools falls back to the base "still working" wording', () => {
280
+ it('stall notice (raw mcp__ tool, with or without label) returns null', () => {
316
281
  expect(
317
- formatFrameworkFallbackText('working', 305_000, []),
318
- ).toBe('still working… (no update from agent in 5 min)')
319
- expect(
320
- formatFrameworkFallbackText('thinking', 305_000, []),
321
- ).toBe('still thinking… (no update from agent in 5 min)')
282
+ formatFrameworkFallbackText('working', 305_000, [
283
+ { name: 'mcp__hindsight__reflect', label: 'Searching memory', durationMs: 305_000 },
284
+ ]),
285
+ ).toBeNull()
322
286
  expect(
323
- formatFrameworkFallbackText('working', 305_000),
324
- ).toBe('still working… (no update from agent in 5 min)')
287
+ formatFrameworkFallbackText('working', 305_000, [
288
+ { name: 'mcp__some-third-party__do_thing', label: null, durationMs: 305_000 },
289
+ ]),
290
+ ).toBeNull()
325
291
  })
326
292
 
327
- it('blockedOnApproval names the real blocker instead of the dishonest "still working…"', () => {
293
+ it('generic stall notice (empty inFlightTools, working AND thinking) returns null', () => {
294
+ expect(formatFrameworkFallbackText('working', 305_000, [])).toBeNull()
295
+ expect(formatFrameworkFallbackText('thinking', 305_000, [])).toBeNull()
296
+ expect(formatFrameworkFallbackText('working', 305_000)).toBeNull()
297
+ })
298
+
299
+ it('blockedOnApproval still names the real blocker (the one surviving user-visible send)', () => {
328
300
  expect(
329
301
  formatFrameworkFallbackText('working', 305_000, [], true),
330
302
  ).toBe('waiting for your approval — tap Approve or Deny on the card above (5 min)')
@@ -338,18 +310,10 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
338
310
  ).toBe('waiting for your approval — tap Approve or Deny on the card above (5 min)')
339
311
  })
340
312
 
341
- it('blockedOnApproval=false keeps the existing wording (default, back-compat)', () => {
313
+ it('blockedOnApproval=false is a stall → returns null (stop-gap retired)', () => {
342
314
  expect(
343
315
  formatFrameworkFallbackText('working', 305_000, [], false),
344
- ).toBe('still working… (no update from agent in 5 min)')
345
- })
346
-
347
- it('tool-aware wording wins over "thinking" — the actual observable beats the inferred kind', () => {
348
- const text = formatFrameworkFallbackText('thinking', 305_000, [
349
- { name: 'Grep', label: '"foo"', durationMs: 305_000 },
350
- ])
351
- expect(text.startsWith('running Grep')).toBe(true)
352
- expect(text).not.toContain('still thinking')
316
+ ).toBeNull()
353
317
  })
354
318
 
355
319
  it('tool completed before the fallback → empty snapshot → base wording', () => {
@@ -450,22 +414,21 @@ describe('silence-poke — #1292 tool-aware framework fallback', () => {
450
414
  expect(__getStateForTests('k')!.inFlightTools.size).toBe(0)
451
415
  })
452
416
 
453
- it('formatFrameworkFallbackText sub-minute durations render as "Ns"', () => {
454
- const text = formatFrameworkFallbackText('working', 305_000, [
455
- { name: 'Grep', label: 'foo', durationMs: 12_000 },
456
- ])
457
- expect(text).toBe(
458
- 'running Grep foo for 12s (no update from agent in 5 min)',
459
- )
460
- })
461
-
462
- it('formatFrameworkFallbackText truncates very long labels', () => {
417
+ it('in-flight tool stall (any duration/label) returns null — stop-gap retired', () => {
418
+ // Previously rendered "running Grep foo for 12s (…)" / truncated long
419
+ // labels. The whole tool-aware stall enrichment is gone now, so the
420
+ // duration + label formatting paths no longer surface — they return null.
421
+ expect(
422
+ formatFrameworkFallbackText('working', 305_000, [
423
+ { name: 'Grep', label: 'foo', durationMs: 12_000 },
424
+ ]),
425
+ ).toBeNull()
463
426
  const longLabel = '"' + 'x'.repeat(120) + '"'
464
- const text = formatFrameworkFallbackText('working', 305_000, [
465
- { name: 'Grep', label: longLabel, durationMs: 305_000 },
466
- ])
467
- expect(text.length).toBeLessThan(120)
468
- expect(text).toContain('…')
427
+ expect(
428
+ formatFrameworkFallbackText('working', 305_000, [
429
+ { name: 'Grep', label: longLabel, durationMs: 305_000 },
430
+ ]),
431
+ ).toBeNull()
469
432
  })
470
433
  })
471
434
 
@@ -528,38 +491,38 @@ describe('silence-poke — fallback handler errors do not break timer', () => {
528
491
  })
529
492
 
530
493
  // CC-4 from `docs/status-ask-cause-classes.md`: wording is load-bearing
531
- // (`reference/rfcs/conversational-pacing.md` § Safety net). Snapshot the exact
532
- // strings here so a refactor that drops a key phrase fails loud at test
533
- // time. If you genuinely need to change the wording, update the snapshot
534
- // AND the design doc together.
494
+ // (`reference/rfcs/conversational-pacing.md` § Safety net). The stall notice is
495
+ // a retired stop-gap — its wording is gone (returns null). The ONE surviving
496
+ // user-visible string is the approval-blocked re-ping; snapshot it here so a
497
+ // refactor that drops the phrase fails loud. The `N min` parenthetical is still
498
+ // derived from silenceMs (honest), so pin that too.
535
499
  describe('silence-poke — wording snapshots (CC-4)', () => {
536
- it('framework fallback — working at 300s', () => {
537
- expect(formatFrameworkFallbackText('working', 300_000)).toMatchInlineSnapshot(
538
- `"still working… (no update from agent in 5 min)"`,
539
- )
500
+ it('framework fallback — generic stall (working/thinking) emits no message', () => {
501
+ expect(formatFrameworkFallbackText('working', 300_000)).toBeNull()
502
+ expect(formatFrameworkFallbackText('thinking', 300_000)).toBeNull()
540
503
  })
541
504
 
542
- it('framework fallback — thinking at 300s', () => {
543
- expect(formatFrameworkFallbackText('thinking', 300_000)).toMatchInlineSnapshot(
544
- `"still thinking… (no update from agent in 5 min)"`,
505
+ it('approval-blocked re-ping — exact wording', () => {
506
+ expect(formatFrameworkFallbackText('working', 300_000, [], true)).toMatchInlineSnapshot(
507
+ `"waiting for your approval — tap Approve or Deny on the card above (5 min)"`,
545
508
  )
546
509
  })
547
510
 
548
- it('framework fallback — minutes derived from silenceMs, not hard-coded', () => {
549
- expect(formatFrameworkFallbackText('working', 360_000)).toBe(
550
- 'still working… (no update from agent in 6 min)',
511
+ it('approval-blocked — minutes derived from silenceMs, not hard-coded', () => {
512
+ expect(formatFrameworkFallbackText('working', 360_000, [], true)).toBe(
513
+ 'waiting for your approval — tap Approve or Deny on the card above (6 min)',
551
514
  )
552
- expect(formatFrameworkFallbackText('working', 600_000)).toBe(
553
- 'still working… (no update from agent in 10 min)',
515
+ expect(formatFrameworkFallbackText('working', 600_000, [], true)).toBe(
516
+ 'waiting for your approval — tap Approve or Deny on the card above (10 min)',
554
517
  )
555
518
  })
556
519
 
557
- it('framework fallback — minutes floor at 1 even when silenceMs is small', () => {
558
- expect(formatFrameworkFallbackText('working', 30_000)).toBe(
559
- 'still working… (no update from agent in 1 min)',
520
+ it('approval-blocked — minutes floor at 1 even when silenceMs is small', () => {
521
+ expect(formatFrameworkFallbackText('working', 30_000, [], true)).toBe(
522
+ 'waiting for your approval — tap Approve or Deny on the card above (1 min)',
560
523
  )
561
- expect(formatFrameworkFallbackText('working', 0)).toBe(
562
- 'still working… (no update from agent in 1 min)',
524
+ expect(formatFrameworkFallbackText('working', 0, [], true)).toBe(
525
+ 'waiting for your approval — tap Approve or Deny on the card above (1 min)',
563
526
  )
564
527
  })
565
528
  })