switchroom 0.14.25 → 0.14.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.
@@ -49420,8 +49420,8 @@ var {
49420
49420
  } = import__.default;
49421
49421
 
49422
49422
  // src/build-info.ts
49423
- var VERSION = "0.14.25";
49424
- var COMMIT_SHA = "f75f4f25";
49423
+ var VERSION = "0.14.27";
49424
+ var COMMIT_SHA = "5ae9596e";
49425
49425
 
49426
49426
  // src/cli/agent.ts
49427
49427
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.25",
3
+ "version": "0.14.27",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,3 +60,65 @@ export function escapeHtml(s: string): string {
60
60
  export function truncate(s: string, n: number): string {
61
61
  return s.length > n ? s.slice(0, n - 1) + '…' : s;
62
62
  }
63
+
64
+ /**
65
+ * Strip Markdown markup from a single line, leaving plain prose.
66
+ *
67
+ * Worker narration is authored as Markdown — the model writes `**bold**`,
68
+ * `` `code` ``, `- bullets`, `# headings`. The status cards render Telegram
69
+ * HTML, which does NOT interpret Markdown, so an unstripped `**` shows up as
70
+ * two literal asterisks (the #94-class "half-done" look). Strip the markup
71
+ * here so the card reads as clean prose.
72
+ *
73
+ * Run this BEFORE `truncate` + `escapeHtml`: clean → measure → escape. (The
74
+ * stripper never touches `<`/`>`/`&`, so escaping stays the last step.)
75
+ */
76
+ export function stripMarkdown(s: string): string {
77
+ let out = s;
78
+ // Inline + leftover code spans → bare text.
79
+ out = out.replace(/`+/g, '');
80
+ // Links / images: [text](url) and ![alt](url) → the label.
81
+ out = out.replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1');
82
+ // Paired bold / emphasis runs (longest marker first).
83
+ out = out.replace(/\*\*(.+?)\*\*/g, '$1');
84
+ out = out.replace(/__(.+?)__/g, '$1');
85
+ out = out.replace(/\*(.+?)\*/g, '$1');
86
+ out = out.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, '$1');
87
+ // Leading block markup: heading, blockquote, bullet, ordered item.
88
+ out = out.replace(/^\s{0,3}#{1,6}\s+/, '');
89
+ out = out.replace(/^\s{0,3}>\s?/, '');
90
+ out = out.replace(/^\s{0,3}[-*+]\s+/, '');
91
+ out = out.replace(/^\s{0,3}\d+[.)]\s+/, '');
92
+ // Residual unpaired bold markers (a lone `*` is left alone so `3 * 4`
93
+ // survives; only the doubled form is markup-by-construction).
94
+ out = out.replace(/\*\*/g, '');
95
+ return out.trim();
96
+ }
97
+
98
+ /** True for a whole-line horizontal rule: `---`, `___`, `***` (3+ of one). */
99
+ function isRuleLine(s: string): boolean {
100
+ return /^\s*([-_*])\1{2,}\s*$/.test(s);
101
+ }
102
+
103
+ /**
104
+ * Clean a worker's multi-line result/narration into a single plain-text
105
+ * paragraph for a card's finished body. Drops fenced code blocks and
106
+ * horizontal rules, strips per-line Markdown, then space-joins what's left.
107
+ * Output is plain text — the caller still truncates + escapes before
108
+ * interpolating into HTML.
109
+ */
110
+ export function cleanWorkerResultParagraph(s: string): string {
111
+ const kept: string[] = [];
112
+ let inFence = false;
113
+ for (const raw of s.split('\n')) {
114
+ if (/^\s*```/.test(raw)) {
115
+ inFence = !inFence;
116
+ continue;
117
+ }
118
+ if (inFence) continue;
119
+ if (isRuleLine(raw)) continue;
120
+ const cleaned = stripMarkdown(raw);
121
+ if (cleaned.length > 0) kept.push(cleaned);
122
+ }
123
+ return kept.join(' ').replace(/\s+/g, ' ').trim();
124
+ }
@@ -6549,6 +6549,43 @@ function escapeHtml(s) {
6549
6549
  function truncate(s, n) {
6550
6550
  return s.length > n ? s.slice(0, n - 1) + "\u2026" : s;
6551
6551
  }
6552
+ function stripMarkdown(s) {
6553
+ let out = s;
6554
+ out = out.replace(/`+/g, "");
6555
+ out = out.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1");
6556
+ out = out.replace(/\*\*(.+?)\*\*/g, "$1");
6557
+ out = out.replace(/__(.+?)__/g, "$1");
6558
+ out = out.replace(/\*(.+?)\*/g, "$1");
6559
+ out = out.replace(/(?<![A-Za-z0-9])_(.+?)_(?![A-Za-z0-9])/g, "$1");
6560
+ out = out.replace(/^\s{0,3}#{1,6}\s+/, "");
6561
+ out = out.replace(/^\s{0,3}>\s?/, "");
6562
+ out = out.replace(/^\s{0,3}[-*+]\s+/, "");
6563
+ out = out.replace(/^\s{0,3}\d+[.)]\s+/, "");
6564
+ out = out.replace(/\*\*/g, "");
6565
+ return out.trim();
6566
+ }
6567
+ function isRuleLine(s) {
6568
+ return /^\s*([-_*])\1{2,}\s*$/.test(s);
6569
+ }
6570
+ function cleanWorkerResultParagraph(s) {
6571
+ const kept = [];
6572
+ let inFence = false;
6573
+ for (const raw of s.split(`
6574
+ `)) {
6575
+ if (/^\s*```/.test(raw)) {
6576
+ inFence = !inFence;
6577
+ continue;
6578
+ }
6579
+ if (inFence)
6580
+ continue;
6581
+ if (isRuleLine(raw))
6582
+ continue;
6583
+ const cleaned = stripMarkdown(raw);
6584
+ if (cleaned.length > 0)
6585
+ kept.push(cleaned);
6586
+ }
6587
+ return kept.join(" ").replace(/\s+/g, " ").trim();
6588
+ }
6552
6589
 
6553
6590
  // ../node_modules/.bun/@grammyjs+runner@2.0.3+c6be0243b1bbec89/node_modules/@grammyjs/runner/out/mod.js
6554
6591
  var require_mod4 = __commonJS((exports) => {
@@ -31966,37 +32003,51 @@ function isWorkerActivityFeedEnabled(envVal) {
31966
32003
  return envVal !== "0";
31967
32004
  }
31968
32005
  var DESC_MAX = 80;
31969
- var TOOL_ARG_MAX = 64;
31970
- var SUMMARY_MAX = 100;
32006
+ var STEP_MAX = 100;
32007
+ var RESULT_MAX = 320;
32008
+ var RULE = "\u2500\u2500\u2500\u2500\u2500";
31971
32009
  var NARRATIVE_MAX_LINES = 6;
32010
+ function appendStepFeed(lines, steps, allDone) {
32011
+ if (steps.length === 0)
32012
+ return;
32013
+ const shown = steps.slice(-NARRATIVE_MAX_LINES);
32014
+ const hidden = steps.length - shown.length;
32015
+ if (hidden > 0)
32016
+ lines.push(`<i>\u2713 +${hidden} earlier\u2026</i>`);
32017
+ const lastIdx = shown.length - 1;
32018
+ shown.forEach((s, i) => {
32019
+ lines.push(!allDone && i === lastIdx ? `<b>\u2192 ${s}</b>` : `<i>\u2713 ${s}</i>`);
32020
+ });
32021
+ }
31972
32022
  function renderWorkerActivity(v) {
31973
- const desc = truncate(v.description.trim() || "background task", DESC_MAX);
32023
+ const desc = truncate(stripMarkdown(v.description).trim() || "background task", DESC_MAX);
31974
32024
  const elapsed = formatDuration(v.elapsedMs);
31975
32025
  const toolWord = v.toolCount === 1 ? "tool" : "tools";
31976
- if (v.state === "done" || v.state === "failed") {
31977
- const head = v.state === "done" ? `\u2705 <b>Worker done</b> \u00b7 <i>${escapeHtml(desc)}</i>` : `\u26a0\ufe0f <b>Worker failed</b> \u00b7 <i>${escapeHtml(desc)}</i>`;
31978
- return `${head}
31979
- <i>${v.toolCount} ${toolWord} \u00b7 ${elapsed}</i>`;
31980
- }
31981
- const header = `\uD83D\uDD27 <b>Worker</b> \u00b7 <i>${escapeHtml(desc)}</i>`;
31982
- let activity;
31983
- if (v.lastTool != null) {
31984
- const arg = v.lastTool.sanitisedArg.trim();
31985
- const argPart = arg.length > 0 ? ` ${escapeHtml(truncate(arg, TOOL_ARG_MAX))}` : "";
31986
- activity = `\u26a1 <code>${escapeHtml(v.lastTool.name)}</code>${argPart} <i>(${v.toolCount} ${toolWord} \u00b7 ${elapsed})</i>`;
32026
+ const header = `\uD83D\uDEE0 <b>Worker</b> \u00b7 <i>${escapeHtml(desc)}</i>`;
32027
+ const finished = v.state === "done" || v.state === "failed";
32028
+ const steps = (v.narrativeLines ?? []).map((s) => stripMarkdown(s)).filter((s) => s.length > 0).map((s) => escapeHtml(truncate(s, STEP_MAX)));
32029
+ if (finished) {
32030
+ const verb = v.state === "done" ? "completed" : "failed";
32031
+ const lines2 = [header, `<i>finished \u00b7 ${verb} \u00b7 ${v.toolCount} ${toolWord} \u00b7 ${elapsed}</i>`];
32032
+ appendStepFeed(lines2, steps, true);
32033
+ const result = cleanWorkerResultParagraph(v.latestSummary);
32034
+ if (result.length > 0) {
32035
+ const emoji = v.state === "done" ? "\u2705" : "\u26a0\ufe0f";
32036
+ lines2.push(RULE);
32037
+ lines2.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`);
32038
+ }
32039
+ return lines2.join(`
32040
+ `);
32041
+ }
32042
+ const lines = [header, `<i>running \u00b7 ${elapsed} \u00b7 ${v.toolCount} ${toolWord}</i>`];
32043
+ if (steps.length > 0) {
32044
+ appendStepFeed(lines, steps, false);
31987
32045
  } else {
31988
- activity = `<i>starting\u2026 (${elapsed})</i>`;
31989
- }
31990
- const lines = [header, activity];
31991
- const narrative = (v.narrativeLines ?? []).map((s) => s.trim()).filter((s) => s.length > 0);
31992
- if (narrative.length > 0) {
31993
- for (const line of narrative) {
31994
- lines.push(` \u21b3 <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`);
31995
- }
31996
- } else {
31997
- const summary = v.latestSummary.trim();
32046
+ const summary = stripMarkdown(v.latestSummary);
31998
32047
  if (summary.length > 0) {
31999
- lines.push(` \u21b3 <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`);
32048
+ lines.push(`<b>\u2192 ${escapeHtml(truncate(summary, STEP_MAX))}</b>`);
32049
+ } else {
32050
+ lines.push("<i>starting\u2026</i>");
32000
32051
  }
32001
32052
  }
32002
32053
  return lines.join(`
@@ -32085,7 +32136,7 @@ function createWorkerActivityFeed(opts) {
32085
32136
  if (nowFn() < h.cooldownUntil) {
32086
32137
  return;
32087
32138
  }
32088
- const body = renderWorkerActivity(view);
32139
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative });
32089
32140
  if (body === h.lastBody)
32090
32141
  return;
32091
32142
  try {
@@ -51469,10 +51520,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
51469
51520
  }
51470
51521
 
51471
51522
  // ../src/build-info.ts
51472
- var VERSION = "0.14.25";
51473
- var COMMIT_SHA = "f75f4f25";
51474
- var COMMIT_DATE = "2026-06-01T00:05:32Z";
51475
- var LATEST_PR = 2038;
51523
+ var VERSION = "0.14.27";
51524
+ var COMMIT_SHA = "5ae9596e";
51525
+ var COMMIT_DATE = "2026-06-01T01:58:04Z";
51526
+ var LATEST_PR = 2042;
51476
51527
  var COMMITS_AHEAD_OF_TAG = 0;
51477
51528
 
51478
51529
  // gateway/boot-version.ts
@@ -17971,7 +17971,7 @@ void (async () => {
17971
17971
  // The watcher's `description` is its 'sub-agent' default (it
17972
17972
  // never reassigns it from the worker jsonl). The dispatch-time
17973
17973
  // task description lives in the registry row — resolveWorkerFeedDispatch
17974
- // prefers it so the header reads "🔧 Worker · <real task>" not
17974
+ // prefers it so the header reads "🛠 Worker · <real task>" not
17975
17975
  // "· sub-agent" (worker-feed-dispatch.ts, pinned by its test).
17976
17976
  let dispatch: WorkerFeedDispatch = resolveWorkerFeedDispatch(null, description)
17977
17977
  if (turnsDb != null) {
@@ -5,7 +5,7 @@ export interface WorkerFeedDispatch {
5
5
  isBackground: boolean
6
6
  /**
7
7
  * The human-readable task to render in the feed header
8
- * ("🔧 Worker · <feedDescription>").
8
+ * ("🛠 Worker · <feedDescription>").
9
9
  */
10
10
  feedDescription: string
11
11
  }
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ cleanWorkerResultParagraph,
4
+ escapeHtml,
5
+ formatDuration,
6
+ stripMarkdown,
7
+ truncate,
8
+ } from '../card-format.js'
9
+
10
+ describe('stripMarkdown', () => {
11
+ it('strips paired bold and emphasis', () => {
12
+ expect(stripMarkdown('a **bold** and *em* and __b__ and _e_')).toBe(
13
+ 'a bold and em and b and e',
14
+ )
15
+ })
16
+
17
+ it('strips inline code spans', () => {
18
+ expect(stripMarkdown('run `git push` now')).toBe('run git push now')
19
+ })
20
+
21
+ it('strips leading headings, blockquotes, bullets, and ordered items', () => {
22
+ expect(stripMarkdown('### Heading')).toBe('Heading')
23
+ expect(stripMarkdown('> quoted')).toBe('quoted')
24
+ expect(stripMarkdown('- a bullet')).toBe('a bullet')
25
+ expect(stripMarkdown('* star bullet')).toBe('star bullet')
26
+ expect(stripMarkdown('1. first')).toBe('first')
27
+ expect(stripMarkdown('2) second')).toBe('second')
28
+ })
29
+
30
+ it('reduces a link to its label', () => {
31
+ expect(stripMarkdown('see [the PR](https://x/y) here')).toBe('see the PR here')
32
+ })
33
+
34
+ it('removes residual doubled markers but keeps a lone asterisk (math)', () => {
35
+ expect(stripMarkdown('**dangling')).toBe('dangling')
36
+ expect(stripMarkdown('3 * 4 = 12')).toBe('3 * 4 = 12')
37
+ })
38
+
39
+ it('does not touch HTML-significant characters (escaping stays separate)', () => {
40
+ expect(stripMarkdown('a < b & c > d')).toBe('a < b & c > d')
41
+ })
42
+ })
43
+
44
+ describe('cleanWorkerResultParagraph', () => {
45
+ it('collapses multi-line Markdown into one plain paragraph', () => {
46
+ const input = '## Done\n\n**PR #21** opened\n\n- merged\n- pushed'
47
+ expect(cleanWorkerResultParagraph(input)).toBe('Done PR #21 opened merged pushed')
48
+ })
49
+
50
+ it('drops fenced code blocks entirely', () => {
51
+ const input = 'before\n```ts\nconst x = 1\n```\nafter'
52
+ expect(cleanWorkerResultParagraph(input)).toBe('before after')
53
+ })
54
+
55
+ it('drops horizontal rules', () => {
56
+ expect(cleanWorkerResultParagraph('a\n---\nb\n***\nc')).toBe('a b c')
57
+ })
58
+
59
+ it('returns empty for whitespace/markup-only input', () => {
60
+ expect(cleanWorkerResultParagraph(' \n---\n')).toBe('')
61
+ })
62
+ })
63
+
64
+ describe('formatDuration', () => {
65
+ it('renders sub-second as ms and seconds/minutes as MM:SS', () => {
66
+ expect(formatDuration(500)).toBe('500ms')
67
+ expect(formatDuration(1000)).toBe('00:01')
68
+ expect(formatDuration(60_000)).toBe('01:00')
69
+ })
70
+ })
71
+
72
+ describe('escapeHtml / truncate', () => {
73
+ it('escapes the three HTML-significant characters', () => {
74
+ expect(escapeHtml('a <b> & c')).toBe('a &lt;b&gt; &amp; c')
75
+ })
76
+ it('truncates with an ellipsis', () => {
77
+ expect(truncate('abcdef', 4)).toBe('abc…')
78
+ expect(truncate('abc', 4)).toBe('abc')
79
+ })
80
+ })
@@ -69,72 +69,122 @@ function makeFakeBot(): FakeBot {
69
69
  // ─── renderWorkerActivity (pure) ─────────────────────────────────────────────
70
70
 
71
71
  describe('renderWorkerActivity', () => {
72
- it('renders running header + tool activity line + summary', () => {
72
+ /** Count of step-feed bullets (`✓` done + `→` in-progress) in a body. */
73
+ const stepCount = (s: string) => (s.match(/[✓→]/g) ?? []).length
74
+
75
+ it('renders the native header + running status + step feed', () => {
73
76
  const out = renderWorkerActivity(view())
74
- expect(out).toContain('🔧 <b>Worker</b> · <i>research competitors</i>')
75
- expect(out).toContain(' <code>Bash</code> grep -r pricing')
76
- expect(out).toContain('(3 tools · ')
77
- expect(out).toContain('↳ <i>scanning vendor pages</i>')
77
+ expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
78
+ expect(out).toContain('running · ')
79
+ expect(out).toContain('3 tools')
80
+ // No narrativeLines → the latestSummary surfaces as the newest `→` step.
81
+ expect(out).toContain('<b>→ scanning vendor pages</b>')
82
+ // The old tool/arg chrome is gone.
83
+ expect(out).not.toContain('⚡')
84
+ expect(out).not.toContain('<code>')
78
85
  })
79
86
 
80
- it('shows a "starting…" line when no tool has run yet', () => {
87
+ it('shows a "starting…" line when no step has run yet', () => {
81
88
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
82
- expect(out).toContain('🔧 <b>Worker</b>')
89
+ expect(out).toContain('🛠 <b>Worker</b>')
83
90
  expect(out).toContain('starting…')
84
- expect(out).not.toContain('')
91
+ expect(out).not.toContain('')
85
92
  })
86
93
 
87
- it('omits the summary line when latestSummary is blank', () => {
94
+ it('falls back to starting… when the summary is blank', () => {
88
95
  const out = renderWorkerActivity(view({ latestSummary: ' ' }))
89
- expect(out).not.toContain('')
96
+ expect(out).toContain('starting…')
97
+ expect(stepCount(out)).toBe(0)
90
98
  })
91
99
 
92
100
  it('uses singular "tool" for a single tool call', () => {
93
101
  const out = renderWorkerActivity(view({ toolCount: 1 }))
94
- expect(out).toContain('(1 tool · ')
102
+ expect(out).toContain('1 tool')
103
+ expect(out).not.toContain('1 tools')
95
104
  })
96
105
 
97
- it('renders a done terminal recap', () => {
98
- const out = renderWorkerActivity(view({ state: 'done', toolCount: 5 }))
99
- expect(out).toContain('✅ <b>Worker done</b> · <i>research competitors</i>')
100
- expect(out).toContain('5 tools · ')
101
- expect(out).not.toContain('')
106
+ it('renders a done terminal recap with a rule + cleaned result', () => {
107
+ const out = renderWorkerActivity(
108
+ view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
109
+ )
110
+ expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
111
+ expect(out).toContain('finished · completed · 5 tools · ')
112
+ expect(out).toContain('─────')
113
+ expect(out).toContain('✅ <i>PR #21 opened</i>')
102
114
  })
103
115
 
104
116
  it('renders a failed terminal recap', () => {
105
- const out = renderWorkerActivity(view({ state: 'failed' }))
106
- expect(out).toContain('⚠️ <b>Worker failed</b>')
117
+ const out = renderWorkerActivity(view({ state: 'failed', latestSummary: 'blew up' }))
118
+ expect(out).toContain('finished · failed · ')
119
+ expect(out).toContain('⚠️ <i>blew up</i>')
107
120
  })
108
121
 
109
- it('grows a narrative block when narrativeLines is present', () => {
122
+ it('omits the rule + result line when the terminal result is empty', () => {
123
+ const out = renderWorkerActivity(view({ state: 'done', latestSummary: ' ' }))
124
+ expect(out).toContain('finished · completed · ')
125
+ expect(out).not.toContain('─────')
126
+ })
127
+
128
+ it('grows a step feed when narrativeLines is present (prior ✓, newest →)', () => {
110
129
  const out = renderWorkerActivity(
111
130
  view({
112
131
  latestSummary: 'newest only — should be ignored',
113
132
  narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
114
133
  }),
115
134
  )
116
- expect(out).toContain('<i>read the brief</i>')
117
- expect(out).toContain('<i>scanned vendor A</i>')
118
- expect(out).toContain(' <i>scanned vendor B</i>')
135
+ expect(out).toContain('<i>✓ read the brief</i>')
136
+ expect(out).toContain('<i>✓ scanned vendor A</i>')
137
+ expect(out).toContain('<b>→ scanned vendor B</b>')
119
138
  // The single-line latestSummary fallback is NOT used when a block is present.
120
139
  expect(out).not.toContain('newest only')
121
- // Three narrative lines → three ↳ lines.
122
- expect(out.match(/↳/g) ?? []).toHaveLength(3)
140
+ expect(stepCount(out)).toBe(3)
123
141
  })
124
142
 
125
143
  it('falls back to latestSummary when narrativeLines is empty', () => {
126
144
  const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
127
- expect(out).toContain(' <i>one line</i>')
128
- expect(out.match(/↳/g) ?? []).toHaveLength(1)
145
+ expect(out).toContain('<b>→ one line</b>')
146
+ expect(stepCount(out)).toBe(1)
129
147
  })
130
148
 
131
- it('drops blank narrative lines from the block', () => {
132
- const out = renderWorkerActivity(
133
- view({ narrativeLines: ['kept', ' ', 'also kept'] }),
149
+ it('drops blank narrative lines from the feed', () => {
150
+ const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
151
+ expect(out).toContain('<i>✓ kept</i>')
152
+ expect(out).toContain('<b>→ also kept</b>')
153
+ expect(stepCount(out)).toBe(2)
154
+ })
155
+
156
+ it('shows an overflow header when the feed exceeds the cap', () => {
157
+ const lines = Array.from({ length: 9 }, (_, i) => `step ${i + 1}`)
158
+ const out = renderWorkerActivity(view({ narrativeLines: lines }))
159
+ expect(out).toContain('<i>✓ +3 earlier…</i>')
160
+ expect(out).not.toContain('step 1')
161
+ expect(out).toContain('<i>✓ step 4</i>')
162
+ expect(out).toContain('<b>→ step 9</b>')
163
+ // 6 visible step lines (the overflow header is not itself a step).
164
+ expect(out.match(/step \d/g) ?? []).toHaveLength(6)
165
+ })
166
+
167
+ it('strips Markdown markup from narrative + description + result', () => {
168
+ const running = renderWorkerActivity(
169
+ view({
170
+ description: '**Build** the `sync`',
171
+ narrativeLines: ['- ran the **full** suite', '`git push`'],
172
+ }),
173
+ )
174
+ expect(running).toContain('🛠 <b>Worker</b> · <i>Build the sync</i>')
175
+ expect(running).toContain('ran the full suite')
176
+ expect(running).toContain('git push')
177
+ expect(running).not.toContain('**')
178
+ expect(running).not.toContain('`')
179
+
180
+ const done = renderWorkerActivity(
181
+ view({ state: 'done', latestSummary: '## Done\n\n**PR #21** opened\n\n---\n`merged`' }),
134
182
  )
135
- expect(out).toContain(' <i>kept</i>')
136
- expect(out).toContain('↳ <i>also kept</i>')
137
- expect(out.match(/↳/g) ?? []).toHaveLength(2)
183
+ expect(done).toContain('Done PR #21 opened merged')
184
+ expect(done).not.toContain('**')
185
+ expect(done).not.toContain('`')
186
+ // The card's own divider is the box-drawing rule, never a raw `---`.
187
+ expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
138
188
  })
139
189
 
140
190
  it('escapes HTML inside narrative lines', () => {
@@ -142,17 +192,11 @@ describe('renderWorkerActivity', () => {
142
192
  expect(out).toContain('a &lt;b&gt;x&lt;/b&gt; &amp; y')
143
193
  })
144
194
 
145
- it('escapes HTML in description, tool, arg, and summary', () => {
195
+ it('escapes HTML in description and summary', () => {
146
196
  const out = renderWorkerActivity(
147
- view({
148
- description: 'a <b>bold</b> task',
149
- lastTool: { name: 'Ba<sh', sanitisedArg: 'x & y' },
150
- latestSummary: 'a > b',
151
- }),
197
+ view({ description: 'a <b>bold</b> task', latestSummary: 'a > b' }),
152
198
  )
153
199
  expect(out).toContain('a &lt;b&gt;bold&lt;/b&gt; task')
154
- expect(out).toContain('Ba&lt;sh')
155
- expect(out).toContain('x &amp; y')
156
200
  expect(out).toContain('a &gt; b')
157
201
  })
158
202
  })
@@ -206,7 +250,7 @@ describe('createWorkerActivityFeed', () => {
206
250
  clock = 13_000 // +3000 since last edit > 2500
207
251
  await feed.update('w1', 'chat', view({ toolCount: 3 }))
208
252
  expect(bot.edits).toHaveLength(1)
209
- expect(bot.edits[0].text).toContain('(3 tools · ')
253
+ expect(bot.edits[0].text).toContain('3 tools')
210
254
  })
211
255
 
212
256
  it('forces a terminal edit on finish, skipping the throttle', async () => {
@@ -219,7 +263,7 @@ describe('createWorkerActivityFeed', () => {
219
263
  clock = 10_500 // well within the throttle window
220
264
  await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
221
265
  expect(bot.edits).toHaveLength(1)
222
- expect(bot.edits[0].text).toContain(' <b>Worker done</b>')
266
+ expect(bot.edits[0].text).toContain('finished · completed · 5 tools')
223
267
  // finish forgets the worker.
224
268
  expect(feed.has('w1')).toBe(false)
225
269
  expect(feed.size).toBe(0)
@@ -303,7 +347,7 @@ describe('createWorkerActivityFeed', () => {
303
347
 
304
348
  await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
305
349
  expect(bot.sent).toHaveLength(1)
306
- expect(bot.sent[0].text).toContain(' <i>read the brief</i>')
350
+ expect(bot.sent[0].text).toContain('<b>→ read the brief</b>')
307
351
 
308
352
  clock = 11_000
309
353
  await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
@@ -311,10 +355,10 @@ describe('createWorkerActivityFeed', () => {
311
355
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
312
356
 
313
357
  const last = bot.edits.at(-1)!
314
- expect(last.text).toContain('<i>read the brief</i>')
315
- expect(last.text).toContain('<i>scanned vendor A</i>')
316
- expect(last.text).toContain(' <i>scanned vendor B</i>')
317
- expect(last.text.match(/↳/g) ?? []).toHaveLength(3)
358
+ expect(last.text).toContain('<i>✓ read the brief</i>')
359
+ expect(last.text).toContain('<i>✓ scanned vendor A</i>')
360
+ expect(last.text).toContain('<b>→ scanned vendor B</b>')
361
+ expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
318
362
  })
319
363
 
320
364
  it('dedups a repeated narrative line so the block does not duplicate', async () => {
@@ -329,7 +373,7 @@ describe('createWorkerActivityFeed', () => {
329
373
  await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'same line' }))
330
374
 
331
375
  const last = bot.edits.at(-1)!
332
- expect(last.text.match(/↳/g) ?? []).toHaveLength(1)
376
+ expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
333
377
  })
334
378
 
335
379
  it('caps the narrative block to the last 6 lines', async () => {
@@ -343,7 +387,7 @@ describe('createWorkerActivityFeed', () => {
343
387
  }
344
388
 
345
389
  const last = bot.edits.at(-1)!
346
- expect(last.text.match(/↳/g) ?? []).toHaveLength(6)
390
+ expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(6)
347
391
  // Oldest lines evicted; newest retained.
348
392
  expect(last.text).not.toContain('line 1')
349
393
  expect(last.text).not.toContain('line 3')
@@ -368,9 +412,9 @@ describe('createWorkerActivityFeed', () => {
368
412
  clock = 13_000
369
413
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
370
414
  const last = bot.edits.at(-1)!
371
- expect(last.text).toContain('<i>line A</i>')
372
- expect(last.text).toContain('<i>line B</i>')
373
- expect(last.text).toContain(' <i>line C</i>')
415
+ expect(last.text).toContain('<i>✓ line A</i>')
416
+ expect(last.text).toContain('<i>✓ line B</i>')
417
+ expect(last.text).toContain('<b>→ line C</b>')
374
418
  })
375
419
 
376
420
  it('forwards threadId as message_thread_id on send', async () => {
@@ -13,16 +13,18 @@ import type { Driver, ObservedMessage, ObservedReaction } from "./driver.js";
13
13
 
14
14
  /**
15
15
  * Canonical shape of a worker-activity-feed message (#2000) as rendered
16
- * in Telegram: a running header `🔧 Worker · …` that edits in place and
17
- * finalizes to `✅ Worker done · …` / `⚠️ Worker failed · …`. The feed is
18
- * default-on fleet-wide as of v0.14.19, so background sub-agent activity
19
- * now surfaces as its own bot message in any chat — including DMs whose
20
- * scenario only cares about the agent's conversational reply.
16
+ * in Telegram: a native card with a `🛠 Worker · …` header that edits in
17
+ * place (running) and finalizes to a `finished · completed/failed · …`
18
+ * status line. The header is present in every state, so it's the primary
19
+ * key. The feed is default-on fleet-wide as of v0.14.19, so background
20
+ * sub-agent activity surfaces as its own bot message in any chat —
21
+ * including DMs whose scenario only cares about the agent's conversational
22
+ * reply.
21
23
  *
22
24
  * Single source of truth; the worker-feed scenario asserts against this,
23
25
  * and recall/reply scenarios exclude it via {@link isWorkerFeedMessage}.
24
26
  */
25
- export const WORKER_FEED_RE = /🔧\s*Worker|✅\s*Worker done|⚠️\s*Worker failed|Worker (?:done|failed)/i;
27
+ export const WORKER_FEED_RE = /🛠[️]?\s*Worker\b|finished\s*·\s*(?:completed|failed)/i;
26
28
 
27
29
  /**
28
30
  * True when `m` is a worker-activity-feed message rather than the agent's
@@ -13,17 +13,23 @@ const feed = (text: string) => ({ text }) as Parameters<typeof isWorkerFeedMessa
13
13
 
14
14
  describe("isWorkerFeedMessage", () => {
15
15
  it("matches the running feed header", () => {
16
- expect(isWorkerFeedMessage(feed("🔧 Worker · crawling changelog · 0:12"))).toBe(true);
16
+ expect(isWorkerFeedMessage(feed("🛠 Worker · crawling changelog"))).toBe(true);
17
+ expect(
18
+ isWorkerFeedMessage(feed("🛠 Worker · crawling changelog\nrunning · 00:12 · 4 tools")),
19
+ ).toBe(true);
17
20
  });
18
21
 
19
- it("matches the terminal done/failed recaps", () => {
20
- expect(isWorkerFeedMessage(feed("✅ Worker done · 10 tools · 1:03"))).toBe(true);
21
- expect(isWorkerFeedMessage(feed("⚠️ Worker failed · 3 tools"))).toBe(true);
22
+ it("matches the terminal finished status (completed/failed)", () => {
23
+ expect(
24
+ isWorkerFeedMessage(feed("🛠 Worker · crawl\nfinished · completed · 10 tools · 01:03")),
25
+ ).toBe(true);
26
+ expect(
27
+ isWorkerFeedMessage(feed("🛠 Worker · crawl\nfinished · failed · 3 tools · 00:08")),
28
+ ).toBe(true);
22
29
  });
23
30
 
24
- it("matches a done/failed header even without the leading emoji", () => {
25
- expect(isWorkerFeedMessage(feed("Worker done · 2 tools"))).toBe(true);
26
- expect(isWorkerFeedMessage(feed("Worker failed mid-step"))).toBe(true);
31
+ it("matches the finished status even on its own line (header-stripped edge)", () => {
32
+ expect(isWorkerFeedMessage(feed("finished · completed · 2 tools · 00:30"))).toBe(true);
27
33
  });
28
34
 
29
35
  it("does NOT match an ordinary agent reply", () => {
@@ -40,7 +46,7 @@ describe("isWorkerFeedMessage", () => {
40
46
  });
41
47
 
42
48
  it("exposes the regex for scenarios that assert on the feed directly", () => {
43
- expect(WORKER_FEED_RE.test("🔧 Worker · x")).toBe(true);
49
+ expect(WORKER_FEED_RE.test("🛠 Worker · x")).toBe(true);
44
50
  });
45
51
  });
46
52
 
@@ -12,12 +12,14 @@
12
12
  * sleep/echo work, so it narrates between tools and the feed can paint
13
13
  * + edit), then asserts:
14
14
  *
15
- * 1. a worker-feed message appears (🔧 Worker · …), distinct from the
15
+ * 1. a worker-feed message appears (🛠 Worker · …), distinct from the
16
16
  * parent's ack reply — proving background activity surfaces after
17
17
  * the parent turn closed;
18
18
  * 2. the message edits in place while work is in flight (body changes
19
19
  * across a window) — proving it's live, not a one-shot post;
20
- * 3. it finalizes to the terminal recap ( Worker done · … / N tools).
20
+ * 3. it finalizes to the terminal recap (finished · completed · N tools);
21
+ * 4. the native card never leaks raw Markdown (no `**`, backticks, or
22
+ * ASCII `---` rule) — the #94-class regression guard.
21
23
  *
22
24
  * It logs every observed body so a human can read the real rendered UX.
23
25
  *
@@ -53,10 +55,11 @@ const BG_DISPATCH_PROMPT =
53
55
  `brief reply saying you've kicked off the background worker so I can ` +
54
56
  `watch its progress.`;
55
57
 
56
- // The feed header rendered in Telegram: "🔧 Worker · <desc>" (running)
57
- // or " Worker done · …" / "⚠️ Worker failed · …" (terminal).
58
- const WORKER_FEED_RE = /🔧\s*Worker|Worker done|Worker failed|⚡/i;
59
- const WORKER_DONE_RE = /✅\s*Worker done|⚠️\s*Worker failed/i;
58
+ // The feed header rendered in Telegram: "🛠 Worker · <desc>" with a
59
+ // "running · …" status (running) or "finished · completed/failed · …"
60
+ // (terminal).
61
+ const WORKER_FEED_RE = /🛠\s*Worker|running\s*·|finished\s*·/i;
62
+ const WORKER_DONE_RE = /finished\s*·\s*(completed|failed)/i;
60
63
 
61
64
  describe("uat: live worker-activity feed (#2000)", () => {
62
65
  it(
@@ -116,6 +119,14 @@ describe("uat: live worker-activity feed (#2000)", () => {
116
119
  expect(doneText!).toMatch(/tools?|tool ·/i);
117
120
  // Did the body actually move between first paint and terminal?
118
121
  expect(doneText).not.toBe(before);
122
+ // #94-class regression guard: the native card strips worker Markdown,
123
+ // so the rendered body must never carry raw `**`, backticks, or an
124
+ // ASCII `---` rule (the finished divider is the box-drawing `─────`).
125
+ expect(doneText!, "raw ** leaked into the card").not.toMatch(/\*\*/);
126
+ expect(doneText!, "raw backtick leaked into the card").not.toContain("`");
127
+ expect(doneText!, "raw --- rule leaked into the card").not.toMatch(
128
+ /(^|\n)\s*-{3,}\s*(\n|$)/,
129
+ );
119
130
  } finally {
120
131
  await sc.tearDown();
121
132
  }
@@ -32,7 +32,13 @@
32
32
  * the feed is fed from watcher callbacks rather than the bridge event stream.
33
33
  */
34
34
 
35
- import { escapeHtml, formatDuration, truncate } from './card-format.js'
35
+ import {
36
+ cleanWorkerResultParagraph,
37
+ escapeHtml,
38
+ formatDuration,
39
+ stripMarkdown,
40
+ truncate,
41
+ } from './card-format.js'
36
42
 
37
43
  /** Worker-activity feed is ON by default; an operator opts out with
38
44
  * SWITCHROOM_WORKER_ACTIVITY_FEED=0. */
@@ -54,10 +60,11 @@ export interface WorkerActivityView {
54
60
  latestSummary: string
55
61
  /**
56
62
  * Accumulated narrative lines, oldest→newest, already deduped + capped by
57
- * the feed manager. When present and non-empty, the render grows a block
58
- * of `↳` lines (mirroring the main agent's live answer) instead of
59
- * collapsing to the single `latestSummary` line. Absent/empty → the
60
- * single-line fallback (back-compat for direct render callers).
63
+ * the feed manager. When present and non-empty, the render grows a `✓`/`→`
64
+ * step feed (prior steps done, newest in-progress mirroring the main
65
+ * agent's activity card) instead of collapsing to the single `latestSummary`
66
+ * line. Absent/empty → the single-line fallback (back-compat for direct
67
+ * render callers).
61
68
  */
62
69
  narrativeLines?: string[]
63
70
  /** Wall-clock since dispatch, ms. */
@@ -80,8 +87,10 @@ export interface BotApiForWorkerFeed {
80
87
  }
81
88
 
82
89
  const DESC_MAX = 80
83
- const TOOL_ARG_MAX = 64
84
- const SUMMARY_MAX = 100
90
+ const STEP_MAX = 100
91
+ const RESULT_MAX = 320
92
+ /** Subtle horizontal rule between the running feed and the finished result. */
93
+ const RULE = '─────'
85
94
  /**
86
95
  * How many trailing narrative lines the live feed keeps visible. The feed
87
96
  * grows like the main agent's answer but can't grow unbounded — Telegram
@@ -91,57 +100,83 @@ const SUMMARY_MAX = 100
91
100
  const NARRATIVE_MAX_LINES = 6
92
101
 
93
102
  /**
94
- * Render the worker-activity message body as Telegram HTML.
103
+ * Append the accumulated step feed to `lines`, mirroring the main agent's
104
+ * activity card (`renderActivityFeed`): prior steps render done (`✓`, italic),
105
+ * the newest renders in-progress (`→`, bold) unless `allDone`, and an overflow
106
+ * header (`✓ +N earlier…`) appears when the feed exceeds NARRATIVE_MAX_LINES.
107
+ * `steps` are already cleaned + escaped HTML.
108
+ */
109
+ function appendStepFeed(lines: string[], steps: string[], allDone: boolean): void {
110
+ if (steps.length === 0) return
111
+ const shown = steps.slice(-NARRATIVE_MAX_LINES)
112
+ const hidden = steps.length - shown.length
113
+ if (hidden > 0) lines.push(`<i>✓ +${hidden} earlier…</i>`)
114
+ const lastIdx = shown.length - 1
115
+ shown.forEach((s, i) => {
116
+ lines.push(!allDone && i === lastIdx ? `<b>→ ${s}</b>` : `<i>✓ ${s}</i>`)
117
+ })
118
+ }
119
+
120
+ /**
121
+ * Render the worker-activity message body as native Telegram HTML, matching
122
+ * the main agent's activity card (`renderActivityFeed` in
123
+ * tool-activity-summary.ts): a `🛠 Worker · <desc>` header, a one-line status,
124
+ * then a `✓`/`→` step feed. Worker narration is authored as Markdown, so every
125
+ * text fragment is run through `stripMarkdown` before escaping — without it the
126
+ * raw `**`/`` ` ``/`---` leak through as literal characters (the "half-done"
127
+ * look we're fixing).
95
128
  *
96
129
  * Layout (running):
97
- * 🔧 <b>Worker</b> · <i>{description}</i>
98
- * <code>{tool}</code> {arg} <i>({n} tools · {elapsed})</i>
99
- *<i>{latest summary}</i>
130
+ * 🛠 <b>Worker</b> · <i>{description}</i>
131
+ * <i>running · {elapsed} · {n} tools</i>
132
+ * <i>✓ {earlier step}</i>
133
+ * <b>→ {newest step}</b>
100
134
  *
101
- * Terminal collapses the activity line to a tool-count + duration recap:
102
- * <b>Worker done</b> · <i>{description}</i>
103
- * <i>{n} tools · {elapsed}</i>
135
+ * Layout (finished): the feed renders all-done, then a rule + cleaned result:
136
+ * 🛠 <b>Worker</b> · <i>{description}</i>
137
+ * <i>finished · completed · {n} tools · {elapsed}</i>
138
+ * <i>✓ {step}</i>
139
+ * ─────
140
+ * ✅ <i>{cleaned result paragraph}</i>
104
141
  */
105
142
  export function renderWorkerActivity(v: WorkerActivityView): string {
106
- const desc = truncate(v.description.trim() || 'background task', DESC_MAX)
143
+ const desc = truncate(stripMarkdown(v.description).trim() || 'background task', DESC_MAX)
107
144
  const elapsed = formatDuration(v.elapsedMs)
108
145
  const toolWord = v.toolCount === 1 ? 'tool' : 'tools'
146
+ const header = `🛠 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
147
+ const finished = v.state === 'done' || v.state === 'failed'
109
148
 
110
- if (v.state === 'done' || v.state === 'failed') {
111
- const head =
112
- v.state === 'done'
113
- ? `✅ <b>Worker done</b> · <i>${escapeHtml(desc)}</i>`
114
- : `⚠️ <b>Worker failed</b> · <i>${escapeHtml(desc)}</i>`
115
- return `${head}\n<i>${v.toolCount} ${toolWord} · ${elapsed}</i>`
116
- }
117
-
118
- const header = `🔧 <b>Worker</b> · <i>${escapeHtml(desc)}</i>`
149
+ const steps = (v.narrativeLines ?? [])
150
+ .map((s) => stripMarkdown(s))
151
+ .filter((s) => s.length > 0)
152
+ .map((s) => escapeHtml(truncate(s, STEP_MAX)))
119
153
 
120
- let activity: string
121
- if (v.lastTool != null) {
122
- const arg = v.lastTool.sanitisedArg.trim()
123
- const argPart = arg.length > 0 ? ` ${escapeHtml(truncate(arg, TOOL_ARG_MAX))}` : ''
124
- activity = `⚡ <code>${escapeHtml(v.lastTool.name)}</code>${argPart} <i>(${v.toolCount} ${toolWord} · ${elapsed})</i>`
125
- } else {
126
- activity = `<i>starting… (${elapsed})</i>`
154
+ if (finished) {
155
+ const verb = v.state === 'done' ? 'completed' : 'failed'
156
+ const lines = [header, `<i>finished · ${verb} · ${v.toolCount} ${toolWord} · ${elapsed}</i>`]
157
+ appendStepFeed(lines, steps, true)
158
+ // On terminal, latestSummary carries the worker's final result text
159
+ // (gateway onFinish), distinct from the running narrative steps.
160
+ const result = cleanWorkerResultParagraph(v.latestSummary)
161
+ if (result.length > 0) {
162
+ const emoji = v.state === 'done' ? '✅' : '⚠️'
163
+ lines.push(RULE)
164
+ lines.push(`${emoji} <i>${escapeHtml(truncate(result, RESULT_MAX))}</i>`)
165
+ }
166
+ return lines.join('\n')
127
167
  }
128
168
 
129
- const lines = [header, activity]
130
-
131
- // Growing narrative block when the manager has accumulated lines; the feed
132
- // reads like the main agent's live answer rather than a single replaced
133
- // status line. Fall back to the single latestSummary line otherwise.
134
- const narrative = (v.narrativeLines ?? [])
135
- .map((s) => s.trim())
136
- .filter((s) => s.length > 0)
137
- if (narrative.length > 0) {
138
- for (const line of narrative) {
139
- lines.push(` ↳ <i>${escapeHtml(truncate(line, SUMMARY_MAX))}</i>`)
140
- }
169
+ const lines = [header, `<i>running · ${elapsed} · ${v.toolCount} ${toolWord}</i>`]
170
+ if (steps.length > 0) {
171
+ appendStepFeed(lines, steps, false)
141
172
  } else {
142
- const summary = v.latestSummary.trim()
173
+ // Back-compat for direct render callers that pass only latestSummary;
174
+ // the manager always supplies narrativeLines.
175
+ const summary = stripMarkdown(v.latestSummary)
143
176
  if (summary.length > 0) {
144
- lines.push(` ↳ <i>${escapeHtml(truncate(summary, SUMMARY_MAX))}</i>`)
177
+ lines.push(`<b>→ ${escapeHtml(truncate(summary, STEP_MAX))}</b>`)
178
+ } else {
179
+ lines.push('<i>starting…</i>')
145
180
  }
146
181
  }
147
182
  return lines.join('\n')
@@ -307,7 +342,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
307
342
  // message is left at its last running render — stale but harmless.
308
343
  return
309
344
  }
310
- const body = renderWorkerActivity(view)
345
+ const body = renderWorkerActivity({ ...view, narrativeLines: h.narrative })
311
346
  if (body === h.lastBody) return
312
347
  try {
313
348
  await opts.bot.editMessageText(h.chatId, h.messageId, body, sendOptsFor(h))