switchroom 0.16.23 โ†’ 0.16.27

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 (138) 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 +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts โ†’ stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -81,13 +81,13 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed
81
81
  it("accumulates distinct actions chronologically (newest = current โ†’ bold, earlier = done โœ“ italic)", () => {
82
82
  const lines: string[] = [];
83
83
  expect(appendActivityLine(lines, "Read", { file_path: "a/gateway.ts" })).toBe(
84
- "<b>โ†’ Reading gateway.ts</b>",
84
+ "**โ†’ Reading gateway.ts**",
85
85
  );
86
86
  expect(appendActivityLine(lines, "mcp__hindsight__reflect", { query: "x" })).toBe(
87
- "<i>โœ“ Reading gateway.ts</i>\n<b>โ†’ Searching memory</b>",
87
+ "_โœ“ Reading gateway.ts_\n**โ†’ Searching memory**",
88
88
  );
89
89
  expect(appendActivityLine(lines, "Bash", { command: "ls", description: "List workspace" })).toBe(
90
- "<i>โœ“ Reading gateway.ts</i>\n<i>โœ“ Searching memory</i>\n<b>โ†’ List workspace</b>",
90
+ "_โœ“ Reading gateway.ts_\n_โœ“ Searching memory_\n**โ†’ List workspace**",
91
91
  );
92
92
  });
93
93
 
@@ -110,24 +110,28 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed
110
110
  const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
111
111
  const out = renderActivityFeed(lines)!;
112
112
  const hidden = total - STATUS_ROLLING_LINES;
113
- expect(out.startsWith(`<i>โœ“ +${hidden} earlierโ€ฆ</i>\n`)).toBe(true);
113
+ expect(out.startsWith(`_โœ“ +${hidden} earlierโ€ฆ_\n`)).toBe(true);
114
114
  // Only the last STATUS_ROLLING_LINES actions are shown; older ones collapsed.
115
115
  const firstVisible = total - STATUS_ROLLING_LINES + 1;
116
- expect(out).toContain(`<i>โœ“ Action ${firstVisible}</i>`);
116
+ expect(out).toContain(`_โœ“ Action ${firstVisible}_`);
117
117
  expect(out).not.toContain(`Action ${firstVisible - 1}<`);
118
118
  // The newest action is the in-progress step (bold โ†’); the rest are done (โœ“).
119
- expect(out).toContain(`<b>โ†’ Action ${total}</b>`);
120
- expect(out).toContain(`<i>โœ“ Action ${total - 1}</i>`);
121
- expect(out).not.toContain(`<b>โ†’ Action ${total - 1}</b>`);
119
+ expect(out).toContain(`**โ†’ Action ${total}**`);
120
+ expect(out).toContain(`_โœ“ Action ${total - 1}_`);
121
+ expect(out).not.toContain(`**โ†’ Action ${total - 1}**`);
122
122
  });
123
123
 
124
- it("HTML-escapes &, <, > in action text (no double-escaping by callers)", () => {
125
- const out = renderActivityFeed(["Running <foo> & <bar>"])!;
126
- expect(out).toBe("<b>โ†’ Running &lt;foo&gt; &amp; &lt;bar&gt;</b>");
124
+ it("markdown-escapes emphasis specials in action text; passes < > & through (#2669)", () => {
125
+ // The per-line pipeline strips PAIRED markdown first, then escapeMarkdown
126
+ // escapes the survivors: only \ ` * _ ~ = [ ] | are escaped โ€” NOT < > &,
127
+ // which are literal in rich markdown. A lone `*` (3 * 4) and an
128
+ // intra-word `_` survive the strip and get escaped.
129
+ const out = renderActivityFeed(["Running <foo> & a_b 3 * 4"])!;
130
+ expect(out).toBe("**โ†’ Running <foo> & a\\_b 3 \\* 4**");
127
131
  });
128
132
 
129
133
  it("renders a single line as the current (bold โ†’) step", () => {
130
- expect(renderActivityFeed(["Reading a.ts"])).toBe("<b>โ†’ Reading a.ts</b>");
134
+ expect(renderActivityFeed(["Reading a.ts"])).toBe("**โ†’ Reading a.ts**");
131
135
  });
132
136
 
133
137
  it("renderActivityFeed returns null on empty", () => {
@@ -141,17 +145,17 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed
141
145
  const lines = ["Reading a.ts", "Searching memory", "Running a command"];
142
146
  const out = renderActivityFeed(lines, true)!;
143
147
  expect(out).toBe(
144
- "<i>โœ“ Reading a.ts</i>\n<i>โœ“ Searching memory</i>\n<i>โœ“ Running a command</i>",
148
+ "_โœ“ Reading a.ts_\n_โœ“ Searching memory_\n_โœ“ Running a command_",
145
149
  );
146
150
  expect(out).not.toContain("โ†’"); // no in-progress arrow anywhere
147
151
  });
148
152
 
149
153
  it("final=true on a single line is also done (โœ“)", () => {
150
- expect(renderActivityFeed(["Reading a.ts"], true)).toBe("<i>โœ“ Reading a.ts</i>");
154
+ expect(renderActivityFeed(["Reading a.ts"], true)).toBe("_โœ“ Reading a.ts_");
151
155
  });
152
156
 
153
157
  it("final defaults false (live render keeps the โ†’ in-progress newest line)", () => {
154
- expect(renderActivityFeed(["Reading a.ts"])).toBe("<b>โ†’ Reading a.ts</b>");
158
+ expect(renderActivityFeed(["Reading a.ts"])).toBe("**โ†’ Reading a.ts**");
155
159
  });
156
160
 
157
161
  // liveSuffix (PR1 heartbeat): appended INSIDE the newest in-progress line so a
@@ -160,19 +164,19 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed
160
164
  describe("liveSuffix (heartbeat)", () => {
161
165
  it("appends the suffix to the newest in-progress line only", () => {
162
166
  expect(renderActivityFeed(["Reading a.ts", "Running a command"], false, " ยท 18s")).toBe(
163
- "<i>โœ“ Reading a.ts</i>\n<b>โ†’ Running a command ยท 18s</b>",
167
+ "_โœ“ Reading a.ts_\n**โ†’ Running a command ยท 18s**",
164
168
  );
165
169
  });
166
170
  it("single live line gets the suffix", () => {
167
171
  expect(renderActivityFeed(["Pulling Meta data"], false, " ยท 1m05s")).toBe(
168
- "<b>โ†’ Pulling Meta data ยท 1m05s</b>",
172
+ "**โ†’ Pulling Meta data ยท 1m05s**",
169
173
  );
170
174
  });
171
175
  it("final=true ignores the suffix (a finalized record never ticks)", () => {
172
176
  const out = renderActivityFeed(["Reading a.ts", "Running a command"], true, " ยท 18s")!;
173
177
  expect(out).not.toContain("ยท");
174
178
  expect(out).not.toContain("โ†’");
175
- expect(out).toBe("<i>โœ“ Reading a.ts</i>\n<i>โœ“ Running a command</i>");
179
+ expect(out).toBe("_โœ“ Reading a.ts_\n_โœ“ Running a command_");
176
180
  });
177
181
  it("default empty suffix is byte-identical to no suffix", () => {
178
182
  expect(renderActivityFeed(["Reading a.ts"], false, "")).toBe(
@@ -185,9 +189,9 @@ describe("appendActivityLine + renderActivityFeed โ€” accumulating activity feed
185
189
  describe("appendActivityLabel โ€” precomputed label feed (tool_label path)", () => {
186
190
  it("accumulates precomputed labels, dedups consecutive, ignores empty", () => {
187
191
  const lines: string[] = [];
188
- expect(appendActivityLabel(lines, "Searching memory")).toBe("<b>โ†’ Searching memory</b>");
192
+ expect(appendActivityLabel(lines, "Searching memory")).toBe("**โ†’ Searching memory**");
189
193
  expect(appendActivityLabel(lines, "List workspace")).toBe(
190
- "<i>โœ“ Searching memory</i>\n<b>โ†’ List workspace</b>",
194
+ "_โœ“ Searching memory_\n**โ†’ List workspace**",
191
195
  );
192
196
  // consecutive dup collapses
193
197
  appendActivityLabel(lines, "List workspace");
@@ -220,7 +224,7 @@ describe("clipNarrative โ€” narrative-step clip (JSONL-text-narrative primitive)
220
224
  const narrativeRender = appendActivityLabel(narrativeLines, clipNarrative(text));
221
225
  const labelRender = appendActivityLabel(labelLines, clipNarrative(text));
222
226
  expect(narrativeRender).toBe(labelRender);
223
- expect(narrativeRender).toBe(`<b>โ†’ ${text}</b>`);
227
+ expect(narrativeRender).toBe(`**โ†’ ${text}**`);
224
228
  });
225
229
 
226
230
  it("empty string yields an empty clip (appendActivityLabel then drops it)", () => {
@@ -234,16 +238,16 @@ describe("renderActivityFeed โ€” โœ“ N steps total (final render, issue #2461)",
234
238
  const lines = ["Reading CLAUDE.md", "Searching memory", "Running a command"];
235
239
  const out = renderActivityFeed(lines, true, "", 5)!;
236
240
  // All lines are done (โœ“) and the footer is appended.
237
- expect(out).toContain("<i>โœ“ Running a command</i>");
238
- expect(out).toContain("<i>โœ“ 5 steps</i>");
239
- expect(out.endsWith("<i>โœ“ 5 steps</i>")).toBe(true);
241
+ expect(out).toContain("_โœ“ Running a command_");
242
+ expect(out).toContain("_โœ“ 5 steps_");
243
+ expect(out.endsWith("_โœ“ 5 steps_")).toBe(true);
240
244
  });
241
245
 
242
246
  it("stepCount=0 โ†’ no footer (no tools fired that were surfaced)", () => {
243
247
  const lines = ["Reading CLAUDE.md"];
244
248
  const out = renderActivityFeed(lines, true, "", 0)!;
245
249
  expect(out).not.toContain("steps");
246
- expect(out).toBe("<i>โœ“ Reading CLAUDE.md</i>");
250
+ expect(out).toBe("_โœ“ Reading CLAUDE.md_");
247
251
  });
248
252
 
249
253
  it("stepCount undefined โ†’ no footer (live/non-final callers omit it)", () => {
@@ -257,22 +261,22 @@ describe("renderActivityFeed โ€” โœ“ N steps total (final render, issue #2461)",
257
261
  const out = renderActivityFeed(lines, false, "", 7)!;
258
262
  expect(out).not.toContain("steps");
259
263
  // The newest line is still the live in-progress step.
260
- expect(out).toContain("<b>โ†’ Running a command</b>");
264
+ expect(out).toContain("**โ†’ Running a command**");
261
265
  });
262
266
 
263
267
  it("stepCount=1 footer reads 'โœ“ 1 steps' (no special-casing)", () => {
264
268
  const lines = ["Reading CLAUDE.md"];
265
269
  const out = renderActivityFeed(lines, true, "", 1)!;
266
- expect(out).toContain("<i>โœ“ 1 steps</i>");
270
+ expect(out).toContain("_โœ“ 1 steps_");
267
271
  });
268
272
 
269
273
  it("footer appears even when the feed overflows the rolling window", () => {
270
274
  const total = STATUS_ROLLING_LINES + 4;
271
275
  const lines = Array.from({ length: total }, (_, i) => `Action ${i + 1}`);
272
276
  const out = renderActivityFeed(lines, true, "", total)!;
273
- expect(out).toContain(`<i>โœ“ +${total - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`);
274
- expect(out).toContain(`<i>โœ“ ${total} steps</i>`);
275
- expect(out.endsWith(`<i>โœ“ ${total} steps</i>`)).toBe(true);
277
+ expect(out).toContain(`_โœ“ +${total - STATUS_ROLLING_LINES} earlierโ€ฆ_`);
278
+ expect(out).toContain(`_โœ“ ${total} steps_`);
279
+ expect(out.endsWith(`_โœ“ ${total} steps_`)).toBe(true);
276
280
  });
277
281
 
278
282
  it("stepCount is surface-tool-excluded: reply/react count 0, Read+mcp count correctly", () => {
@@ -283,7 +287,7 @@ describe("renderActivityFeed โ€” โœ“ N steps total (final render, issue #2461)",
283
287
  const lines = ["Reading CLAUDE.md", "Searching memory"]; // 2 non-surface labels
284
288
  // stepCount=2 (Read + mcp__hindsight__recall) โ€” reply is NOT counted.
285
289
  const out = renderActivityFeed(lines, true, "", 2)!;
286
- expect(out).toContain("<i>โœ“ 2 steps</i>");
290
+ expect(out).toContain("_โœ“ 2 steps_");
287
291
  // stepCount=0 would mean only surface tools fired โ€” no footer.
288
292
  const outSurfaceOnly = renderActivityFeed(["Reading CLAUDE.md"], true, "", 0)!;
289
293
  expect(outSurfaceOnly).not.toContain("steps");
@@ -303,33 +307,33 @@ describe("renderActivityFeedWithNested โ€” foreground sub-agent nesting (Model A
303
307
  const child = ["Reading schema.ts", "Looking for foreign keys"];
304
308
  const out = renderActivityFeedWithNested(parent, child)!;
305
309
  // Parent is blocked at the Task tool โ†’ none of its lines is the live step.
306
- expect(out).toContain("<i>โœ“ Searching memory</i>");
307
- expect(out).toContain("<i>โœ“ Delegating: review the migration</i>");
308
- expect(out).not.toContain("<b>โ†’ Delegating");
310
+ expect(out).toContain("_โœ“ Searching memory_");
311
+ expect(out).toContain("_โœ“ Delegating: review the migration_");
312
+ expect(out).not.toContain("**โ†’ Delegating");
309
313
  // The live โ†’ step is the newest nested child line; earlier child = italic.
310
- expect(out).toContain(" โ†ณ <i>Reading schema.ts</i>");
311
- expect(out).toContain(" โ†ณ <b>โ†’ Looking for foreign keys</b>");
314
+ expect(out).toContain(" โ†ณ _Reading schema.ts_");
315
+ expect(out).toContain(" โ†ณ **โ†’ Looking for foreign keys**");
312
316
  });
313
317
 
314
318
  it("windows the nested block to STATUS_ROLLING_LINES with a 'โ†ณ +N earlierโ€ฆ' header", () => {
315
319
  const total = STATUS_ROLLING_LINES + 3;
316
320
  const child = Array.from({ length: total }, (_, i) => `step ${i + 1}`);
317
321
  const out = renderActivityFeedWithNested(["Delegating: x"], child)!;
318
- expect(out).toContain(` โ†ณ <i>+${total - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`);
322
+ expect(out).toContain(` โ†ณ _+${total - STATUS_ROLLING_LINES} earlierโ€ฆ_`);
319
323
  // newest nested line is the live โ†’ step
320
- expect(out).toContain(` โ†ณ <b>โ†’ step ${total}</b>`);
324
+ expect(out).toContain(` โ†ณ **โ†’ step ${total}**`);
321
325
  // the oldest (collapsed) lines are not rendered verbatim
322
326
  expect(out).not.toContain("step 1<");
323
327
  });
324
328
 
325
329
  it("renders the child block even when the parent feed is empty", () => {
326
330
  const out = renderActivityFeedWithNested([], ["Reading a.ts"]);
327
- expect(out).toBe(" โ†ณ <b>โ†’ Reading a.ts</b>");
331
+ expect(out).toBe(" โ†ณ **โ†’ Reading a.ts**");
328
332
  });
329
333
 
330
- it("HTML-escapes nested child text", () => {
331
- const out = renderActivityFeedWithNested(["Delegating: x"], ["touch <a> & <b>"])!;
332
- expect(out).toContain(" โ†ณ <b>โ†’ touch &lt;a&gt; &amp; &lt;b&gt;</b>");
334
+ it("markdown-escapes nested child text (emphasis specials only)", () => {
335
+ const out = renderActivityFeedWithNested(["Delegating: x"], ["touch a_b & 2 * 3"])!;
336
+ expect(out).toContain(" โ†ณ **โ†’ touch a\\_b & 2 \\* 3**");
333
337
  });
334
338
 
335
339
  it("final=true: the nested newest step renders done (โœ“), not in-progress (โ†’)", () => {
@@ -338,13 +342,13 @@ describe("renderActivityFeedWithNested โ€” foreground sub-agent nesting (Model A
338
342
  ["Reading schema.ts", "Looking for foreign keys"],
339
343
  true,
340
344
  )!;
341
- expect(out).toContain(" โ†ณ <i>Looking for foreign keys</i>"); // newest now italic done
345
+ expect(out).toContain(" โ†ณ _Looking for foreign keys_"); // newest now italic done
342
346
  expect(out).not.toContain("โ†’"); // no in-progress arrow in the finalized feed
343
347
  });
344
348
 
345
349
  it("final=true with no children delegates to the finalized flat render", () => {
346
350
  expect(renderActivityFeedWithNested(["Reading a.ts"], [], true)).toBe(
347
- "<i>โœ“ Reading a.ts</i>",
351
+ "_โœ“ Reading a.ts_",
348
352
  );
349
353
  });
350
354
 
@@ -355,13 +359,13 @@ describe("renderActivityFeedWithNested โ€” foreground sub-agent nesting (Model A
355
359
  false,
356
360
  " ยท 22s",
357
361
  )!;
358
- expect(out).toContain(" โ†ณ <b>โ†’ Looking for foreign keys ยท 22s</b>");
362
+ expect(out).toContain(" โ†ณ **โ†’ Looking for foreign keys ยท 22s**");
359
363
  expect(out).not.toContain("Reading schema.ts ยท "); // only the newest line ticks
360
364
  });
361
365
 
362
366
  it("liveSuffix passes through to the flat render when there are no children", () => {
363
367
  expect(renderActivityFeedWithNested(["Reading a.ts"], [], false, " ยท 9s")).toBe(
364
- "<b>โ†’ Reading a.ts ยท 9s</b>",
368
+ "**โ†’ Reading a.ts ยท 9s**",
365
369
  );
366
370
  });
367
371
 
@@ -369,15 +373,15 @@ describe("renderActivityFeedWithNested โ€” foreground sub-agent nesting (Model A
369
373
  const parent = ["Delegating: review the migration"];
370
374
  const child = ["Reading schema.ts", "Looking for foreign keys"];
371
375
  const out = renderActivityFeedWithNested(parent, child, true, "", 5)!;
372
- expect(out).toContain("<i>โœ“ 5 steps</i>");
373
- expect(out.endsWith("<i>โœ“ 5 steps</i>")).toBe(true);
376
+ expect(out).toContain("_โœ“ 5 steps_");
377
+ expect(out.endsWith("_โœ“ 5 steps_")).toBe(true);
374
378
  expect(out).not.toContain("โ†’");
375
379
  });
376
380
 
377
381
  it("stepCount footer appears on final=true with no children (delegates to flat render)", () => {
378
382
  const out = renderActivityFeedWithNested(["Reading a.ts"], [], true, "", 3)!;
379
- expect(out).toContain("<i>โœ“ 3 steps</i>");
380
- expect(out).toBe("<i>โœ“ Reading a.ts</i>\n<i>โœ“ 3 steps</i>");
383
+ expect(out).toContain("_โœ“ 3 steps_");
384
+ expect(out).toBe("_โœ“ Reading a.ts_\n_โœ“ 3 steps_");
381
385
  });
382
386
 
383
387
  // Liveness-driven feed open (dark-turn fix): a turn that emits no tool_label
@@ -389,12 +393,12 @@ describe("renderActivityFeedWithNested โ€” foreground sub-agent nesting (Model A
389
393
  it("renders the live in-progress placeholder with climbing elapsed", () => {
390
394
  expect(
391
395
  renderActivityFeedWithNested(["Workingโ€ฆ"], [], false, " ยท 12s"),
392
- ).toBe("<b>โ†’ Workingโ€ฆ ยท 12s</b>");
396
+ ).toBe("**โ†’ Workingโ€ฆ ยท 12s**");
393
397
  });
394
398
 
395
399
  it("finalizes the placeholder to a done record (no frozen โ†’ line)", () => {
396
400
  expect(renderActivityFeedWithNested(["Workingโ€ฆ"], [], true)).toBe(
397
- "<i>โœ“ Workingโ€ฆ</i>",
401
+ "_โœ“ Workingโ€ฆ_",
398
402
  );
399
403
  });
400
404
 
@@ -470,8 +474,8 @@ describe("rolling window + +N earlier โ€” renderActivityFeed", () => {
470
474
  expect(out).not.toContain(`XAct-${String(i).padStart(3, '0')}`);
471
475
  }
472
476
  // Overflow header present on the AGENT surface now.
473
- expect(out).toContain(`<i>โœ“ +${12 - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`);
474
- expect(out).toContain(`<b>โ†’ XAct-012</b>`);
477
+ expect(out).toContain(`_โœ“ +${12 - STATUS_ROLLING_LINES} earlierโ€ฆ_`);
478
+ expect(out).toContain(`**โ†’ XAct-012**`);
475
479
  });
476
480
 
477
481
  it("STATUS_LINE_MAX=200: a 250-char step is clipped to 200 with a trailing โ€ฆ", () => {
@@ -521,8 +525,8 @@ describe("rolling window + +N earlier โ€” renderActivityFeedWithNested", () => {
521
525
  for (let i = 1; i < firstVisible; i++) {
522
526
  expect(out).not.toContain(`Parent ${i}`);
523
527
  }
524
- expect(out).toContain(`<i>โœ“ +${totalParent - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`);
525
- expect(out).toContain(" โ†ณ <b>โ†’ Child step B</b>");
528
+ expect(out).toContain(`_โœ“ +${totalParent - STATUS_ROLLING_LINES} earlierโ€ฆ_`);
529
+ expect(out).toContain(" โ†ณ **โ†’ Child step B**");
526
530
  });
527
531
 
528
532
  it("many child lines โ†’ only the last STATUS_ROLLING_LINES child lines render with a โ†ณ +N earlier header", () => {
@@ -537,8 +541,8 @@ describe("rolling window + +N earlier โ€” renderActivityFeedWithNested", () => {
537
541
  for (let i = 1; i < firstVisible; i++) {
538
542
  expect(out).not.toContain(`Child ${i}`);
539
543
  }
540
- expect(out).toContain(` โ†ณ <i>+${totalChild - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`);
541
- expect(out).toContain(` โ†ณ <b>โ†’ Child ${totalChild}</b>`);
544
+ expect(out).toContain(` โ†ณ _+${totalChild - STATUS_ROLLING_LINES} earlierโ€ฆ_`);
545
+ expect(out).toContain(` โ†ณ **โ†’ Child ${totalChild}**`);
542
546
  });
543
547
 
544
548
  it("STATUS_LINE_MAX=200: a 250-char child line is clipped to 200 (both surfaces)", () => {
@@ -559,37 +563,31 @@ describe("rolling window + +N earlier โ€” renderActivityFeedWithNested", () => {
559
563
  });
560
564
  });
561
565
 
562
- // โ”€โ”€โ”€ Extreme-edge: single oversized line with HTML-special chars โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
563
- // These tests cover the _fitToCharBudget and _fitNestedToCharBudget fallback
564
- // paths that are reachable in no-truncate mode when a Bash label contains
565
- // special chars (e.g. && is extremely common) and is longer than the budget.
566
-
567
- /** Cheap valid-HTML checker: balanced tags + no partial entity. */
568
- function isValidHtml(s: string): boolean {
569
- // No partial entity: must not end with &[a-z]* lacking semicolon.
570
- if (/&[a-z]+$/.test(s)) return false;
571
- if (/&[a-z]+[^;]$/.test(s)) return false;
572
- // Every &...; must be complete.
573
- const entityRe = /&([^;]*)/g;
574
- let m: RegExpExecArray | null;
575
- while ((m = entityRe.exec(s)) !== null) {
576
- if (!s.slice(m.index).startsWith("&") || s[m.index + m[0].length] !== ";") {
577
- // Check if this entity is actually terminated
578
- const entityStart = m.index;
579
- const semiIdx = s.indexOf(";", entityStart + 1);
580
- if (semiIdx === -1) return false; // no closing semicolon
581
- }
582
- }
583
- // All opening tags have a corresponding close (simple check for <b>/<i>).
584
- const bOpen = (s.match(/<b>/g) ?? []).length;
585
- const bClose = (s.match(/<\/b>/g) ?? []).length;
586
- const iOpen = (s.match(/<i>/g) ?? []).length;
587
- const iClose = (s.match(/<\/i>/g) ?? []).length;
588
- return bOpen === bClose && iOpen === iClose;
566
+ // โ”€โ”€โ”€ Extreme-edge: single oversized line with markdown-special chars โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
567
+ // These tests cover the fitCardToBudget fallback path that is reachable when a
568
+ // Bash label contains special chars (e.g. && is extremely common) and is
569
+ // longer than the budget. Post-#2669 the wire format is GFM markdown.
570
+
571
+ /**
572
+ * Cheap valid-rich-markdown checker: the emphasis wrappers the renderer
573
+ * emits (`**โ€ฆ**`, `_โ€ฆ_`) must be balanced, and no escape sequence may be
574
+ * left dangling at the end (a trailing lone backslash would consume the
575
+ * closing marker).
576
+ */
577
+ function isValidMarkdown(s: string): boolean {
578
+ // No dangling escape backslash at the very end.
579
+ // Count trailing backslashes โ€” an odd count means the final char escapes
580
+ // nothing real and would swallow the wrapper close.
581
+ const trailing = /(\\*)$/.exec(s)?.[1].length ?? 0;
582
+ if (trailing % 2 === 1) return false;
583
+ // Balanced ** bold wrappers (count of '**' must be even).
584
+ const bold = (s.match(/\*\*/g) ?? []).length;
585
+ if (bold % 2 !== 0) return false;
586
+ return true;
589
587
  }
590
588
 
591
- describe("extreme-edge: single oversized line with &, <, >, &&", () => {
592
- it("renderActivityFeed: single ~4100-char line with special chars โ†’ โ‰ค budget and valid HTML", () => {
589
+ describe("extreme-edge: single oversized line with markdown specials & && _ *", () => {
590
+ it("renderActivityFeed: single ~4100-char line with special chars โ†’ โ‰ค budget and valid markdown", () => {
593
591
  // Build a raw line >4000 chars with &, <, >, and a trailing &&.
594
592
  // The && will escape to &amp;&amp; (5 chars each), exercising the expansion guard.
595
593
  const base = "Run script with args: foo=bar && baz=<qux> & corge=1 ";
@@ -599,13 +597,13 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
599
597
  const out = renderActivityFeed([bigLine])!;
600
598
  expect(out).not.toBeNull();
601
599
  expect(out.length).toBeLessThanOrEqual(4000); // STATUS_CARD_CHAR_BUDGET
602
- expect(isValidHtml(out)).toBe(true);
603
- // Must be wrapped in <b>โ€ฆ</b> (live/non-final) โ€” no dangling tag.
604
- expect(out.startsWith("<b>โ†’ ")).toBe(true);
605
- expect(out.endsWith("</b>")).toBe(true);
600
+ expect(isValidMarkdown(out)).toBe(true);
601
+ // Must be wrapped in **โ€ฆ** (live/non-final) โ€” no dangling marker.
602
+ expect(out.startsWith("**โ†’ ")).toBe(true);
603
+ expect(out.endsWith("**")).toBe(true);
606
604
  });
607
605
 
608
- it("renderActivityFeed final=true: single ~4100-char line โ†’ โ‰ค budget and valid HTML", () => {
606
+ it("renderActivityFeed final=true: single ~4100-char line โ†’ โ‰ค budget and valid markdown", () => {
609
607
  const base = "Deploy build && upload && notify && cleanup: ";
610
608
  const bigLine = base.repeat(90) + "done";
611
609
  expect(bigLine.length).toBeGreaterThan(4000);
@@ -613,12 +611,12 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
613
611
  const out = renderActivityFeed([bigLine], true)!;
614
612
  expect(out).not.toBeNull();
615
613
  expect(out.length).toBeLessThanOrEqual(4000);
616
- expect(isValidHtml(out)).toBe(true);
617
- expect(out.startsWith("<i>โœ“ ")).toBe(true);
618
- expect(out.endsWith("</i>")).toBe(true);
614
+ expect(isValidMarkdown(out)).toBe(true);
615
+ expect(out.startsWith("_โœ“ ")).toBe(true);
616
+ expect(out.endsWith("_")).toBe(true);
619
617
  });
620
618
 
621
- it("renderActivityFeedWithNested: single ~4100-char parent line โ†’ โ‰ค budget and valid HTML", () => {
619
+ it("renderActivityFeedWithNested: single ~4100-char parent line โ†’ โ‰ค budget and valid markdown", () => {
622
620
  const base = "Parent action with & < > special chars && bash: ";
623
621
  const bigLine = base.repeat(85);
624
622
  expect(bigLine.length).toBeGreaterThan(4000);
@@ -626,10 +624,10 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
626
624
  const out = renderActivityFeedWithNested([bigLine], [])!;
627
625
  expect(out).not.toBeNull();
628
626
  expect(out.length).toBeLessThanOrEqual(4000);
629
- expect(isValidHtml(out)).toBe(true);
627
+ expect(isValidMarkdown(out)).toBe(true);
630
628
  });
631
629
 
632
- it("renderActivityFeedWithNested: single ~4100-char child line โ†’ โ‰ค budget and valid HTML", () => {
630
+ it("renderActivityFeedWithNested: single ~4100-char child line โ†’ โ‰ค budget and valid markdown", () => {
633
631
  const base = "Child step: run build && test && deploy with <args> & flags=1 ";
634
632
  const bigChild = base.repeat(65) + "&&";
635
633
  expect(bigChild.length).toBeGreaterThan(4000);
@@ -637,19 +635,19 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
637
635
  const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild])!;
638
636
  expect(out).not.toBeNull();
639
637
  expect(out.length).toBeLessThanOrEqual(4000);
640
- expect(isValidHtml(out)).toBe(true);
638
+ expect(isValidMarkdown(out)).toBe(true);
641
639
  // Must contain the nested prefix.
642
640
  expect(out).toContain(" โ†ณ ");
643
641
  // Regression guard: an operator-precedence bug made wrapperOverhead a string
644
642
  // (NESTED_PREFIX + n) โ†’ NaN budget โ†’ slice(0, NaN) === "" โ†’ the child content
645
- // was silently discarded, leaving only " โ†ณ <b>โ†’ </b>". The empty-wrapper
643
+ // was silently discarded, leaving only " โ†ณ **โ†’ **". The empty-wrapper
646
644
  // output still satisfies the toContain(" โ†ณ ") check above, so assert that
647
645
  // real child content actually survives.
648
646
  expect(out).toContain("Child step");
649
647
  expect(out.length).toBeGreaterThan(100);
650
648
  });
651
649
 
652
- it("renderActivityFeedWithNested final=true: single ~4100-char child line โ†’ โ‰ค budget and valid HTML", () => {
650
+ it("renderActivityFeedWithNested final=true: single ~4100-char child line โ†’ โ‰ค budget and valid markdown", () => {
653
651
  const base = "Final child: compile && link && package & ship: ";
654
652
  const bigChild = base.repeat(85);
655
653
  expect(bigChild.length).toBeGreaterThan(4000);
@@ -657,7 +655,7 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
657
655
  const out = renderActivityFeedWithNested(["Delegating: big job"], [bigChild], true)!;
658
656
  expect(out).not.toBeNull();
659
657
  expect(out.length).toBeLessThanOrEqual(4000);
660
- expect(isValidHtml(out)).toBe(true);
658
+ expect(isValidMarkdown(out)).toBe(true);
661
659
  // final=true โ†’ nested line renders done (italic, no โ†’).
662
660
  expect(out).not.toContain("โ†’");
663
661
  });
@@ -673,30 +671,30 @@ describe("extreme-edge: single oversized line with &, <, >, &&", () => {
673
671
  describe("renderActivityHeader โ€” two-line header builder", () => {
674
672
  it("renders the running header with elapsed and tool count", () => {
675
673
  const [h1, h2] = renderActivityHeader("๐Ÿค–", "Agent", "", 15_000, 7, "running");
676
- expect(h1).toBe("๐Ÿค– <b>Agent</b>");
677
- expect(h2).toBe("<i>15s ยท 7 tools</i>");
674
+ expect(h1).toBe("๐Ÿค– **Agent**");
675
+ expect(h2).toBe("_15s ยท 7 tools_");
678
676
  });
679
677
 
680
678
  it("renders the done header with tool count and elapsed", () => {
681
679
  const [h1, h2] = renderActivityHeader("๐Ÿค–", "Agent", "", 65_000, 3, "done");
682
- expect(h1).toBe("๐Ÿค– <b>Agent</b>");
683
- expect(h2).toBe("<i>done ยท 3 tools ยท 1m05s</i>");
680
+ expect(h1).toBe("๐Ÿค– **Agent**");
681
+ expect(h2).toBe("_done ยท 3 tools ยท 1m05s_");
684
682
  });
685
683
 
686
684
  it("includes the description in line 1 when non-empty", () => {
687
685
  const [h1] = renderActivityHeader("๐Ÿ› ", "Worker", "run tests", 10_000, 2, "running");
688
- expect(h1).toBe("๐Ÿ›  <b>Worker</b> ยท <i>run tests</i>");
686
+ expect(h1).toBe("๐Ÿ›  **Worker** ยท _run tests_");
689
687
  });
690
688
 
691
689
  it("omits the description part when empty", () => {
692
690
  const [h1] = renderActivityHeader("๐Ÿค–", "Agent", "", 5_000, 1, "running");
693
- expect(h1).toBe("๐Ÿค– <b>Agent</b>");
691
+ expect(h1).toBe("๐Ÿค– **Agent**");
694
692
  expect(h1).not.toContain(" ยท ");
695
693
  });
696
694
 
697
- it("HTML-escapes special chars in description and label", () => {
698
- const [h1] = renderActivityHeader("๐Ÿค–", "Agent", "run <foo> & <bar>", 5_000, 1, "running");
699
- expect(h1).toContain("run &lt;foo&gt; &amp; &lt;bar&gt;");
695
+ it("markdown-escapes emphasis specials in description and label", () => {
696
+ const [h1] = renderActivityHeader("๐Ÿค–", "Agent", "run a_b & c*d", 5_000, 1, "running");
697
+ expect(h1).toContain("run a\\_b & c\\*d");
700
698
  });
701
699
  });
702
700
 
@@ -711,35 +709,36 @@ describe("agent flat path routes through the shared step-feed primitive", () =>
711
709
  );
712
710
  // And the +N earlier marker is present on the flat agent surface.
713
711
  expect(renderActivityFeed(lines)!).toContain(
714
- `<i>โœ“ +${lines.length - STATUS_ROLLING_LINES} earlierโ€ฆ</i>`,
712
+ `_โœ“ +${lines.length - STATUS_ROLLING_LINES} earlierโ€ฆ_`,
715
713
  );
716
714
  });
717
715
  });
718
716
 
719
- describe("escape entity is never split mid-clip (clip raw โ†’ escape last)", () => {
720
- it("a step ending in '&' clipped at STATUS_LINE_MAX stays valid HTML", () => {
721
- // Build a step exactly STATUS_LINE_MAX+1 chars ending in '&' so a naive
722
- // escape-then-clip would split the &amp; entity at the boundary.
723
- const line = "x".repeat(STATUS_LINE_MAX) + "&";
717
+ describe("markdown escape is never split mid-clip (clip raw โ†’ escape last, #2669)", () => {
718
+ it("a step ending in a markdown special '*' clipped at STATUS_LINE_MAX stays valid", () => {
719
+ // The clip happens on the RAW string and escaping is the LAST per-line op,
720
+ // so the trailing '*' that survives the clip is escaped to a clean '\*'
721
+ // โ€” never a dangling backslash that would eat the bold close marker.
722
+ const line = "x".repeat(STATUS_LINE_MAX) + "*";
724
723
  const out = renderActivityFeed([line])!;
725
- // The clip keeps STATUS_LINE_MAX-1 chars + 'โ€ฆ', then escapes โ€” no stray entity.
726
- expect(out).not.toMatch(/&amp$/);
727
- expect(out).not.toMatch(/&am[^p]/);
728
724
  expect(out).toContain("โ€ฆ");
729
- // Whatever '&' survives must be a complete &amp;.
730
- const ampCount = (out.match(/&amp;/g) ?? []).length;
731
- const bareAmp = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
732
- expect(bareAmp).toBe(0);
733
- expect(ampCount).toBeGreaterThanOrEqual(0);
734
- });
735
-
736
- it("a step ending in '<' clipped at STATUS_LINE_MAX stays valid HTML", () => {
737
- const line = "y".repeat(STATUS_LINE_MAX) + "<";
725
+ // The clip lands before the trailing '*', so the only '*' chars present are
726
+ // the renderer's own ** wrappers (an even count) โ€” no stray odd escaped one.
727
+ const stars = (out.match(/\*/g) ?? []).length;
728
+ expect(stars % 2).toBe(0);
729
+ // No dangling escape backslash that would swallow a following marker.
730
+ expect(out).not.toMatch(/\\$/);
731
+ });
732
+
733
+ it("a step whose clipped tail IS a markdown special escapes cleanly", () => {
734
+ // Place an underscore right at the clip boundary so the surviving char is a
735
+ // special โ€” it must be escaped as '\_' with a balanced backslash.
736
+ const line = "y".repeat(STATUS_LINE_MAX - 1) + "_more";
738
737
  const out = renderActivityFeed([line])!;
739
- expect(out).not.toMatch(/&lt$/);
740
- expect(out).not.toMatch(/&l[^t;]/);
741
- const bareLt = (out.match(/&(?!amp;|lt;|gt;)/g) ?? []).length;
742
- expect(bareLt).toBe(0);
738
+ // Any backslash present must be paired with the special it escapes โ€” no
739
+ // odd trailing backslash.
740
+ const trailing = /(\\*)$/.exec(out)?.[1].length ?? 0;
741
+ expect(trailing % 2).toBe(0);
743
742
  });
744
743
  });
745
744
 
@@ -768,11 +767,11 @@ describe("renderActivityFeed โ€” header param (main-session card fix)", () => {
768
767
  };
769
768
  const out = renderActivityFeed(["Reading CLAUDE.md", "Searching memory"], false, "", undefined, header)!;
770
769
  // Must start with the header lines.
771
- expect(out).toContain("๐Ÿค– <b>Agent</b>");
772
- expect(out).toContain("<i>15s ยท 7 tools</i>");
770
+ expect(out).toContain("๐Ÿค– **Agent**");
771
+ expect(out).toContain("_15s ยท 7 tools_");
773
772
  // Step feed follows the header.
774
- expect(out).toContain("<i>โœ“ Reading CLAUDE.md</i>");
775
- expect(out).toContain("<b>โ†’ Searching memory</b>");
773
+ expect(out).toContain("_โœ“ Reading CLAUDE.md_");
774
+ expect(out).toContain("**โ†’ Searching memory**");
776
775
  });
777
776
 
778
777
  it("prepends the done header when final=true", () => {
@@ -783,9 +782,9 @@ describe("renderActivityFeed โ€” header param (main-session card fix)", () => {
783
782
  state: "done",
784
783
  };
785
784
  const out = renderActivityFeed(["Reading CLAUDE.md"], true, "", undefined, header)!;
786
- expect(out).toContain("๐Ÿค– <b>Agent</b>");
787
- expect(out).toContain("<i>done ยท 3 tools ยท 1m05s</i>");
788
- expect(out).toContain("<i>โœ“ Reading CLAUDE.md</i>");
785
+ expect(out).toContain("๐Ÿค– **Agent**");
786
+ expect(out).toContain("_done ยท 3 tools ยท 1m05s_");
787
+ expect(out).toContain("_โœ“ Reading CLAUDE.md_");
789
788
  // No in-progress arrow (final=true).
790
789
  expect(out).not.toContain("โ†’");
791
790
  });
@@ -800,7 +799,7 @@ describe("renderActivityFeed โ€” header param (main-session card fix)", () => {
800
799
  // renderActivityFeed normally returns null for empty lines; with header it returns content.
801
800
  const out = renderActivityFeed([], false, "", undefined, header);
802
801
  expect(out).not.toBeNull();
803
- expect(out).toContain("๐Ÿค– <b>Agent</b>");
802
+ expect(out).toContain("๐Ÿค– **Agent**");
804
803
  });
805
804
 
806
805
  it("without header, renderActivityFeed still returns null for empty lines (no regression)", () => {
@@ -822,12 +821,12 @@ describe("renderActivityFeed โ€” header param (main-session card fix)", () => {
822
821
  undefined,
823
822
  header,
824
823
  )!;
825
- expect(out).toContain("๐Ÿค– <b>Agent</b>");
826
- expect(out).toContain("<i>30s ยท 5 tools</i>");
824
+ expect(out).toContain("๐Ÿค– **Agent**");
825
+ expect(out).toContain("_30s ยท 5 tools_");
827
826
  // Parent step is done-styled (child is the live step).
828
- expect(out).toContain("<i>โœ“ Delegating: review</i>");
827
+ expect(out).toContain("_โœ“ Delegating: review_");
829
828
  // Child step is the in-progress step.
830
- expect(out).toContain(" โ†ณ <b>โ†’ Reading schema.ts</b>");
829
+ expect(out).toContain(" โ†ณ **โ†’ Reading schema.ts**");
831
830
  });
832
831
  });
833
832