verbalcoding 0.2.11 → 0.2.13

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 (235) hide show
  1. package/.env.example +98 -2
  2. package/README.es.md +134 -0
  3. package/README.fr.md +134 -0
  4. package/README.ja.md +134 -0
  5. package/README.ko.md +134 -0
  6. package/README.md +118 -74
  7. package/README.ru.md +134 -0
  8. package/README.zh.md +133 -0
  9. package/app-node/agent_adapters.mjs +37 -5
  10. package/app-node/agent_adapters.test.mjs +27 -1
  11. package/app-node/agent_detect.mjs +73 -0
  12. package/app-node/agent_detect.test.mjs +77 -0
  13. package/app-node/agent_routing.mjs +148 -0
  14. package/app-node/agent_routing.test.mjs +138 -0
  15. package/app-node/agent_turn.mjs +86 -0
  16. package/app-node/agent_turn.test.mjs +109 -0
  17. package/app-node/bridge_context.mjs +73 -0
  18. package/app-node/bridge_context.test.mjs +54 -0
  19. package/app-node/bridge_state.mjs +4 -0
  20. package/app-node/bridge_wireup.test.mjs +462 -0
  21. package/app-node/cli_install.test.mjs +31 -0
  22. package/app-node/cross_agent_routing.test.mjs +78 -0
  23. package/app-node/discord_command_router.mjs +204 -0
  24. package/app-node/discord_command_router.test.mjs +311 -0
  25. package/app-node/discord_voice_setup.mjs +251 -0
  26. package/app-node/discord_voice_setup.test.mjs +86 -0
  27. package/app-node/hermes_profiles.test.mjs +12 -1
  28. package/app-node/install_config.mjs +113 -3
  29. package/app-node/install_config.test.mjs +8 -0
  30. package/app-node/instance_doctor.test.mjs +9 -0
  31. package/app-node/instances.test.mjs +8 -1
  32. package/app-node/main.mjs +513 -1058
  33. package/app-node/mcp_tools.test.mjs +7 -0
  34. package/app-node/notification_handler.mjs +89 -0
  35. package/app-node/notification_handler.test.mjs +187 -0
  36. package/app-node/notify.mjs +73 -0
  37. package/app-node/notify.test.mjs +68 -0
  38. package/app-node/plan_dispatcher.mjs +215 -0
  39. package/app-node/plan_dispatcher.test.mjs +101 -0
  40. package/app-node/plan_mode.mjs +203 -0
  41. package/app-node/plan_mode.test.mjs +231 -0
  42. package/app-node/progress_handler.mjs +220 -0
  43. package/app-node/progress_handler.test.mjs +193 -0
  44. package/app-node/progress_speech.mjs +54 -32
  45. package/app-node/progress_speech.test.mjs +12 -3
  46. package/app-node/project_sessions.mjs +5 -2
  47. package/app-node/project_sessions.test.mjs +7 -0
  48. package/app-node/research_mode.mjs +282 -0
  49. package/app-node/research_mode.test.mjs +264 -0
  50. package/app-node/restart_notice.mjs +3 -0
  51. package/app-node/restart_notice.test.mjs +11 -0
  52. package/app-node/session_ontology.mjs +271 -0
  53. package/app-node/session_ontology.test.mjs +130 -0
  54. package/app-node/smart_progress.mjs +94 -0
  55. package/app-node/smart_progress.test.mjs +66 -0
  56. package/app-node/stream_sentencer.mjs +91 -0
  57. package/app-node/stream_sentencer.test.mjs +129 -0
  58. package/app-node/streaming_tts_queue.mjs +52 -0
  59. package/app-node/streaming_tts_queue.test.mjs +64 -0
  60. package/app-node/stt_whisper.mjs +24 -0
  61. package/app-node/stt_whisper.test.mjs +32 -0
  62. package/app-node/text_routing.mjs +22 -0
  63. package/app-node/text_routing.test.mjs +23 -1
  64. package/app-node/tts_backends.mjs +537 -3
  65. package/app-node/tts_backends.test.mjs +454 -0
  66. package/app-node/tts_player.mjs +164 -0
  67. package/app-node/tts_player.test.mjs +202 -0
  68. package/app-node/tts_runtime.mjs +134 -0
  69. package/app-node/tts_runtime.test.mjs +89 -0
  70. package/app-node/tts_settings.mjs +150 -3
  71. package/app-node/tts_settings.test.mjs +204 -0
  72. package/app-node/tts_voice_config.mjs +136 -2
  73. package/app-node/tts_voice_config.test.mjs +94 -0
  74. package/app-node/utterance_router.mjs +216 -0
  75. package/app-node/utterance_router.test.mjs +236 -0
  76. package/app-node/voice_autojoin.mjs +37 -0
  77. package/app-node/voice_autojoin.test.mjs +59 -0
  78. package/app-node/voice_io.mjs +272 -0
  79. package/app-node/voice_io.test.mjs +102 -0
  80. package/app-node/voice_turn_runner.mjs +449 -0
  81. package/app-node/voice_turn_runner.test.mjs +289 -0
  82. package/docs/CONFIGURATION.md +79 -96
  83. package/docs/FRESH_INSTALL.md +105 -63
  84. package/docs/HARNESSES.md +58 -0
  85. package/docs/HARNESS_AIDER.md +50 -0
  86. package/docs/HARNESS_CLAUDE.md +56 -0
  87. package/docs/HARNESS_CODEX.md +56 -0
  88. package/docs/HARNESS_CURSOR.md +45 -0
  89. package/docs/HARNESS_GEMINI.md +45 -0
  90. package/docs/HARNESS_HERMES.md +57 -0
  91. package/docs/HARNESS_OPENCLAW.md +44 -0
  92. package/docs/HARNESS_OPENCODE.md +44 -0
  93. package/docs/HERMES_VOICE.md +65 -0
  94. package/docs/MULTI_INSTANCE.md +16 -0
  95. package/docs/README.md +50 -0
  96. package/docs/RELEASE.md +42 -19
  97. package/docs/ROADMAP.md +53 -0
  98. package/docs/TROUBLESHOOTING.md +126 -0
  99. package/docs/TTS_BACKENDS.md +227 -0
  100. package/docs/USAGE.md +94 -40
  101. package/docs/assets/figures/verbalcoding-flow.svg +1 -1
  102. package/docs/i18n/AGENTS.es.md +34 -0
  103. package/docs/i18n/AGENTS.fr.md +34 -0
  104. package/docs/i18n/AGENTS.ja.md +34 -0
  105. package/docs/i18n/AGENTS.ko.md +34 -0
  106. package/docs/i18n/AGENTS.ru.md +34 -0
  107. package/docs/i18n/AGENTS.zh.md +34 -0
  108. package/docs/i18n/CONFIGURATION.es.md +25 -0
  109. package/docs/i18n/CONFIGURATION.fr.md +25 -0
  110. package/docs/i18n/CONFIGURATION.ja.md +25 -0
  111. package/docs/i18n/CONFIGURATION.ko.md +25 -0
  112. package/docs/i18n/CONFIGURATION.ru.md +25 -0
  113. package/docs/i18n/CONFIGURATION.zh.md +25 -0
  114. package/docs/i18n/FRESH_INSTALL.es.md +27 -2
  115. package/docs/i18n/FRESH_INSTALL.fr.md +27 -2
  116. package/docs/i18n/FRESH_INSTALL.ja.md +27 -2
  117. package/docs/i18n/FRESH_INSTALL.ko.md +27 -2
  118. package/docs/i18n/FRESH_INSTALL.ru.md +27 -2
  119. package/docs/i18n/FRESH_INSTALL.zh.md +27 -2
  120. package/docs/i18n/HARNESSES.es.md +58 -0
  121. package/docs/i18n/HARNESSES.fr.md +58 -0
  122. package/docs/i18n/HARNESSES.ja.md +58 -0
  123. package/docs/i18n/HARNESSES.ko.md +58 -0
  124. package/docs/i18n/HARNESSES.ru.md +58 -0
  125. package/docs/i18n/HARNESSES.zh.md +58 -0
  126. package/docs/i18n/HARNESS_AIDER.es.md +48 -0
  127. package/docs/i18n/HARNESS_AIDER.fr.md +48 -0
  128. package/docs/i18n/HARNESS_AIDER.ja.md +50 -0
  129. package/docs/i18n/HARNESS_AIDER.ko.md +50 -0
  130. package/docs/i18n/HARNESS_AIDER.ru.md +48 -0
  131. package/docs/i18n/HARNESS_AIDER.zh.md +48 -0
  132. package/docs/i18n/HARNESS_CLAUDE.es.md +55 -0
  133. package/docs/i18n/HARNESS_CLAUDE.fr.md +55 -0
  134. package/docs/i18n/HARNESS_CLAUDE.ja.md +56 -0
  135. package/docs/i18n/HARNESS_CLAUDE.ko.md +56 -0
  136. package/docs/i18n/HARNESS_CLAUDE.ru.md +55 -0
  137. package/docs/i18n/HARNESS_CLAUDE.zh.md +56 -0
  138. package/docs/i18n/HARNESS_CODEX.es.md +55 -0
  139. package/docs/i18n/HARNESS_CODEX.fr.md +55 -0
  140. package/docs/i18n/HARNESS_CODEX.ja.md +56 -0
  141. package/docs/i18n/HARNESS_CODEX.ko.md +56 -0
  142. package/docs/i18n/HARNESS_CODEX.ru.md +55 -0
  143. package/docs/i18n/HARNESS_CODEX.zh.md +56 -0
  144. package/docs/i18n/HARNESS_CURSOR.es.md +42 -0
  145. package/docs/i18n/HARNESS_CURSOR.fr.md +42 -0
  146. package/docs/i18n/HARNESS_CURSOR.ja.md +45 -0
  147. package/docs/i18n/HARNESS_CURSOR.ko.md +45 -0
  148. package/docs/i18n/HARNESS_CURSOR.ru.md +42 -0
  149. package/docs/i18n/HARNESS_CURSOR.zh.md +42 -0
  150. package/docs/i18n/HARNESS_GEMINI.es.md +44 -0
  151. package/docs/i18n/HARNESS_GEMINI.fr.md +44 -0
  152. package/docs/i18n/HARNESS_GEMINI.ja.md +45 -0
  153. package/docs/i18n/HARNESS_GEMINI.ko.md +45 -0
  154. package/docs/i18n/HARNESS_GEMINI.ru.md +44 -0
  155. package/docs/i18n/HARNESS_GEMINI.zh.md +45 -0
  156. package/docs/i18n/HARNESS_HERMES.es.md +54 -0
  157. package/docs/i18n/HARNESS_HERMES.fr.md +54 -0
  158. package/docs/i18n/HARNESS_HERMES.ja.md +57 -0
  159. package/docs/i18n/HARNESS_HERMES.ko.md +57 -0
  160. package/docs/i18n/HARNESS_HERMES.ru.md +54 -0
  161. package/docs/i18n/HARNESS_HERMES.zh.md +57 -0
  162. package/docs/i18n/HARNESS_OPENCLAW.es.md +41 -0
  163. package/docs/i18n/HARNESS_OPENCLAW.fr.md +41 -0
  164. package/docs/i18n/HARNESS_OPENCLAW.ja.md +44 -0
  165. package/docs/i18n/HARNESS_OPENCLAW.ko.md +44 -0
  166. package/docs/i18n/HARNESS_OPENCLAW.ru.md +41 -0
  167. package/docs/i18n/HARNESS_OPENCLAW.zh.md +42 -0
  168. package/docs/i18n/HARNESS_OPENCODE.es.md +41 -0
  169. package/docs/i18n/HARNESS_OPENCODE.fr.md +41 -0
  170. package/docs/i18n/HARNESS_OPENCODE.ja.md +44 -0
  171. package/docs/i18n/HARNESS_OPENCODE.ko.md +44 -0
  172. package/docs/i18n/HARNESS_OPENCODE.ru.md +41 -0
  173. package/docs/i18n/HARNESS_OPENCODE.zh.md +44 -0
  174. package/docs/i18n/HERMES_VOICE.es.md +46 -0
  175. package/docs/i18n/HERMES_VOICE.fr.md +46 -0
  176. package/docs/i18n/HERMES_VOICE.ja.md +46 -0
  177. package/docs/i18n/HERMES_VOICE.ko.md +65 -0
  178. package/docs/i18n/HERMES_VOICE.ru.md +46 -0
  179. package/docs/i18n/HERMES_VOICE.zh.md +46 -0
  180. package/docs/i18n/MULTI_INSTANCE.es.md +25 -0
  181. package/docs/i18n/MULTI_INSTANCE.fr.md +25 -0
  182. package/docs/i18n/MULTI_INSTANCE.ja.md +25 -0
  183. package/docs/i18n/MULTI_INSTANCE.ko.md +25 -0
  184. package/docs/i18n/MULTI_INSTANCE.ru.md +25 -0
  185. package/docs/i18n/MULTI_INSTANCE.zh.md +25 -0
  186. package/docs/i18n/README.es.md +20 -134
  187. package/docs/i18n/README.fr.md +20 -134
  188. package/docs/i18n/README.ja.md +20 -134
  189. package/docs/i18n/README.ko.md +20 -133
  190. package/docs/i18n/README.ru.md +20 -134
  191. package/docs/i18n/README.zh.md +20 -133
  192. package/docs/i18n/RELEASE.es.md +26 -1
  193. package/docs/i18n/RELEASE.fr.md +26 -1
  194. package/docs/i18n/RELEASE.ja.md +26 -1
  195. package/docs/i18n/RELEASE.ko.md +26 -1
  196. package/docs/i18n/RELEASE.ru.md +26 -1
  197. package/docs/i18n/RELEASE.zh.md +26 -1
  198. package/docs/i18n/TROUBLESHOOTING.es.md +39 -0
  199. package/docs/i18n/TROUBLESHOOTING.fr.md +39 -0
  200. package/docs/i18n/TROUBLESHOOTING.ja.md +39 -0
  201. package/docs/i18n/TROUBLESHOOTING.ko.md +39 -0
  202. package/docs/i18n/TROUBLESHOOTING.ru.md +39 -0
  203. package/docs/i18n/TROUBLESHOOTING.zh.md +39 -0
  204. package/docs/i18n/USAGE.es.md +25 -0
  205. package/docs/i18n/USAGE.fr.md +25 -0
  206. package/docs/i18n/USAGE.ja.md +25 -0
  207. package/docs/i18n/USAGE.ko.md +25 -0
  208. package/docs/i18n/USAGE.ru.md +25 -0
  209. package/docs/i18n/USAGE.zh.md +25 -0
  210. package/docs/superpowers/plans/2026-05-13-phase1-streaming-pipeline.md +122 -0
  211. package/docs/superpowers/plans/2026-05-13-phase10-push-notifications.md +152 -0
  212. package/docs/superpowers/plans/2026-05-13-phase2-agent-adapters.md +242 -0
  213. package/docs/superpowers/plans/2026-05-13-phase6-smart-progress.md +172 -0
  214. package/docs/superpowers/plans/2026-05-13-phase7-voice-plan-mode.md +108 -0
  215. package/docs/superpowers/plans/2026-05-14-cross-agent-voice-transfer.md +625 -0
  216. package/docs/superpowers/plans/2026-05-21-audio-overview-narrated-diffs.md +95 -0
  217. package/docs/superpowers/plans/2026-05-21-autoresearch-ontology.md +83 -0
  218. package/docs/superpowers/plans/2026-05-21-phase11-push-to-talk-wakeword-v2.md +77 -0
  219. package/docs/superpowers/plans/2026-05-21-phase12-multi-user-voice.md +147 -0
  220. package/docs/superpowers/plans/2026-05-21-phase14-verbalbench.md +136 -0
  221. package/docs/superpowers/plans/2026-05-21-phase15-phone-companion.md +72 -0
  222. package/integrations/fireredtts2/mlx_llm.py +183 -0
  223. package/integrations/fireredtts2/synth.py +156 -0
  224. package/integrations/fireredtts2/synth_mlx.py +196 -0
  225. package/integrations/mlxaudio/synth.py +74 -0
  226. package/integrations/neuttsair/synth.py +104 -0
  227. package/integrations/omnivoice/synth.py +110 -0
  228. package/package.json +7 -1
  229. package/scripts/cli.mjs +88 -3
  230. package/scripts/doctor.mjs +115 -4
  231. package/scripts/install.mjs +20 -2
  232. package/scripts/install_fireredtts2.sh +109 -0
  233. package/scripts/install_mlxaudio.sh +34 -0
  234. package/scripts/install_mossttsnano.sh +46 -0
  235. package/scripts/postinstall.mjs +34 -0
@@ -0,0 +1,220 @@
1
+ // Agent progress speech pipeline: queues verbose progress events into rate-limited
2
+ // TTS batches, plays them through the shared bridge player, and stops cleanly
3
+ // when the agent finally answers.
4
+ //
5
+ // Phase 5a extraction from main.mjs. Closes over bridge state
6
+ // (activeProgressSignal, activeProgressAbortController, smartProgressSummarizer,
7
+ // progressSpeechBatch, etc.) plus a handful of injected helpers (settings, log,
8
+ // warn, playAudio, synthProgressTTS deps, refreshTtsRuntimeConfig).
9
+ //
10
+ // Module-level imports (createSmartProgressSummarizer, progressCategory,
11
+ // formatProgressMessage, summarizeProgressEvents, progressTtsCacheFileName)
12
+ // move with the handler — they're not needed anywhere else in main.mjs.
13
+
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { createSmartProgressSummarizer } from './smart_progress.mjs';
17
+ import { progressCategory, formatProgressMessage, summarizeProgressEvents } from './progress_speech.mjs';
18
+ import { progressTtsCacheFileName } from './progress_cache.mjs';
19
+
20
+ export function createProgressHandler(deps) {
21
+ const {
22
+ bridge,
23
+ settings,
24
+ log,
25
+ warn,
26
+ isAbortError,
27
+ playAudio,
28
+ sendText,
29
+ refreshTtsRuntimeConfig,
30
+ } = deps;
31
+
32
+ function ensureSmartProgressSummarizer() {
33
+ if (bridge.smartProgressSummarizer) return bridge.smartProgressSummarizer;
34
+ bridge.smartProgressSummarizer = createSmartProgressSummarizer({
35
+ apiKey: process.env.SMART_PROGRESS_API_KEY || '',
36
+ baseUrl: process.env.SMART_PROGRESS_BASE_URL || 'https://api.groq.com/openai/v1',
37
+ model: process.env.SMART_PROGRESS_MODEL || 'llama-3.1-8b-instant',
38
+ language: settings.voiceLanguage,
39
+ });
40
+ bridge.smartProgressSummarizer.on('summary', summary => {
41
+ if (!summary || !bridge.activeProgressSignal) return;
42
+ queueVerboseProgressSpeech(summary, bridge.activeProgressSignal);
43
+ });
44
+ return bridge.smartProgressSummarizer;
45
+ }
46
+
47
+ function smartProgressStatusText() {
48
+ const hasKey = Boolean(process.env.SMART_PROGRESS_API_KEY);
49
+ const mode = bridge.smartProgressEnabled && hasKey ? 'on' : 'off';
50
+ const reason = !hasKey ? ' (no SMART_PROGRESS_API_KEY set)' : '';
51
+ return `smart-progress: ${mode}${reason}`;
52
+ }
53
+
54
+ function progressEmoji(event) {
55
+ const category = progressCategory(event, { language: settings.voiceLanguage })?.key;
56
+ return {
57
+ test: '🧪',
58
+ edit: '✏️',
59
+ read: '📖',
60
+ search: '🔎',
61
+ terminal: '⌨️',
62
+ skill: '🧰',
63
+ browser: '🌐',
64
+ tool: '🛠️',
65
+ agent: '🤖',
66
+ work: '⚙️',
67
+ }[category] || '⚙️';
68
+ }
69
+
70
+ function formatProgressText(event) {
71
+ return formatProgressMessage(event, { language: settings.voiceLanguage });
72
+ }
73
+
74
+ function sendVerboseProgressText(event, signal) {
75
+ if (!bridge.verboseProgress || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
76
+ const formatted = formatProgressText(event).replace(/\s+/g, ' ').trim();
77
+ if (!formatted) return;
78
+ const message = formatted.slice(0, 1900);
79
+ const now = Date.now();
80
+ if (message === bridge.lastVerboseProgressText && now - bridge.lastVerboseProgressTextAt < 2000) return;
81
+ bridge.lastVerboseProgressText = message;
82
+ bridge.lastVerboseProgressTextAt = now;
83
+ void sendText(message).catch(e => warn('verbose progress text delivery failed', e?.stack || e));
84
+ }
85
+
86
+ async function synthProgressTTS(text, signal) {
87
+ await refreshTtsRuntimeConfig();
88
+ const ext = bridge.ttsBackend.outputExtension || 'mp3';
89
+ const cachePath = path.join(settings.tts.progressCacheDir, progressTtsCacheFileName({
90
+ backendKeyParts: bridge.ttsBackend.cacheKeyParts(),
91
+ text,
92
+ ext,
93
+ }));
94
+ if (fs.existsSync(cachePath) && fs.statSync(cachePath).size > 0) {
95
+ log('progress tts cache hit', text, cachePath);
96
+ return cachePath;
97
+ }
98
+ log('progress tts cache miss', text);
99
+ const tmp = await bridge.ttsBackend.synthesize(text, { signal, kind: 'progress' });
100
+ fs.renameSync(tmp, cachePath);
101
+ return cachePath;
102
+ }
103
+
104
+ async function speakProgress(text, signal) {
105
+ if (signal?.aborted) return;
106
+ try {
107
+ const mp3 = await synthProgressTTS(text, signal);
108
+ if (signal?.aborted) return;
109
+ await playAudio(mp3, { deleteAfter: false });
110
+ } catch (e) {
111
+ if (!isAbortError(e)) warn('progress tts failed', e?.stack || e);
112
+ }
113
+ }
114
+
115
+ async function speakImmediateNotice(text, signal, reason = 'notice') {
116
+ if (signal?.aborted) return;
117
+ try {
118
+ log('immediate notice speech', reason, 'text', String(text || '').slice(0, 80));
119
+ const mp3 = await synthProgressTTS(text, signal);
120
+ if (signal?.aborted) return;
121
+ await playAudio(mp3, { deleteAfter: false });
122
+ } catch (e) {
123
+ if (!isAbortError(e)) warn('immediate notice speech failed', reason, e?.stack || e);
124
+ }
125
+ }
126
+
127
+ function queueProgressSpeechText(text, signal, reason = 'status') {
128
+ const spoken = String(text || '').replace(/\s+/g, ' ').trim();
129
+ if (!spoken || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
130
+ bridge.verboseProgressSpeechQueue = bridge.verboseProgressSpeechQueue
131
+ .catch(() => {})
132
+ .then(async () => {
133
+ if (signal.aborted || bridge.activeProgressSignal !== signal || !bridge.processing) return;
134
+ log('progress speech queued', reason, 'text', spoken);
135
+ await speakProgress(spoken, signal);
136
+ });
137
+ }
138
+
139
+ function flushProgressSpeechBatch(signal, reason = 'timer') {
140
+ if (!signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
141
+ if (bridge.progressSpeechBatchTimer) {
142
+ clearTimeout(bridge.progressSpeechBatchTimer);
143
+ bridge.progressSpeechBatchTimer = null;
144
+ }
145
+ const events = bridge.progressSpeechBatch;
146
+ bridge.progressSpeechBatch = [];
147
+ bridge.progressSpeechBatchSignal = null;
148
+ bridge.progressSpeechBatchStartedAt = 0;
149
+ const text = summarizeProgressEvents(events, { maxCategories: 3, language: settings.voiceLanguage });
150
+ if (!text) return;
151
+ queueProgressSpeechText(text, signal, `batch-${reason}-${events.length}`);
152
+ }
153
+
154
+ function queueVerboseProgressSpeech(event, signal) {
155
+ if (!bridge.verboseProgress || !signal || signal.aborted || bridge.activeProgressSignal !== signal) return;
156
+ const text = String(event || '').replace(/\s+/g, ' ').trim().slice(0, 120);
157
+ if (!text) return;
158
+ if (bridge.progressSpeechBatchSignal && bridge.progressSpeechBatchSignal !== signal) {
159
+ bridge.progressSpeechBatch = [];
160
+ if (bridge.progressSpeechBatchTimer) clearTimeout(bridge.progressSpeechBatchTimer);
161
+ bridge.progressSpeechBatchTimer = null;
162
+ bridge.progressSpeechBatchStartedAt = 0;
163
+ }
164
+ bridge.progressSpeechBatchSignal = signal;
165
+ if (!bridge.progressSpeechBatchStartedAt) bridge.progressSpeechBatchStartedAt = Date.now();
166
+ bridge.progressSpeechBatch.push(text);
167
+ const elapsedMs = Date.now() - bridge.progressSpeechBatchStartedAt;
168
+ const ratePerSecond = bridge.progressSpeechBatch.length / Math.max(0.2, elapsedMs / 1000);
169
+ const maxBatchEvents = ratePerSecond >= 6 ? 5 : ratePerSecond >= 3 ? 4 : 3;
170
+ const batchDelayMs = ratePerSecond >= 6 ? 650 : ratePerSecond >= 3 ? 550 : 450;
171
+ if (bridge.progressSpeechBatch.length >= maxBatchEvents) {
172
+ flushProgressSpeechBatch(signal, 'full');
173
+ return;
174
+ }
175
+ if (bridge.progressSpeechBatchTimer) clearTimeout(bridge.progressSpeechBatchTimer);
176
+ bridge.progressSpeechBatchTimer = setTimeout(() => flushProgressSpeechBatch(signal, 'timer'), batchDelayMs);
177
+ }
178
+
179
+ function clearProgressSpeechBatch(signal = bridge.activeProgressSignal) {
180
+ if (bridge.progressSpeechBatchTimer) {
181
+ clearTimeout(bridge.progressSpeechBatchTimer);
182
+ bridge.progressSpeechBatchTimer = null;
183
+ }
184
+ if (!signal || bridge.progressSpeechBatchSignal === signal) {
185
+ bridge.progressSpeechBatch = [];
186
+ bridge.progressSpeechBatchSignal = null;
187
+ bridge.progressSpeechBatchStartedAt = 0;
188
+ }
189
+ }
190
+
191
+ function stopProgressSpeech(signal, reason = 'final-answer') {
192
+ if (bridge.activeProgressSignal !== signal) return;
193
+ clearProgressSpeechBatch(signal);
194
+ bridge.activeProgressSignal = null;
195
+ if (bridge.activeProgressAbortController && !bridge.activeProgressAbortController.signal.aborted) {
196
+ try { bridge.activeProgressAbortController.abort(); } catch (e) { warn('abort progress speech failed', e?.stack || e); }
197
+ }
198
+ if (bridge.speaking) {
199
+ log('stop progress speech before final answer', reason);
200
+ try { bridge.player.stop(true); } catch (e) { warn('stop progress speech failed', e?.stack || e); }
201
+ bridge.speaking = false;
202
+ }
203
+ }
204
+
205
+ return {
206
+ ensureSmartProgressSummarizer,
207
+ smartProgressStatusText,
208
+ progressEmoji,
209
+ formatProgressText,
210
+ sendVerboseProgressText,
211
+ synthProgressTTS,
212
+ speakProgress,
213
+ speakImmediateNotice,
214
+ queueProgressSpeechText,
215
+ flushProgressSpeechBatch,
216
+ queueVerboseProgressSpeech,
217
+ clearProgressSpeechBatch,
218
+ stopProgressSpeech,
219
+ };
220
+ }
@@ -0,0 +1,193 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { createProgressHandler } from './progress_handler.mjs';
4
+ import { createBridge } from './bridge_context.mjs';
5
+
6
+ const noop = () => {};
7
+ const noopAsync = async () => {};
8
+
9
+ function makeDeps(overrides = {}) {
10
+ const bridge = createBridge();
11
+ bridge.ttsBackend = {
12
+ outputExtension: 'mp3',
13
+ cacheKeyParts: () => ['fake'],
14
+ synthesize: async () => '/tmp/progress-tmp.mp3',
15
+ };
16
+ return {
17
+ bridge,
18
+ settings: { tts: { progressCacheDir: '/tmp/progress-cache' }, voiceLanguage: 'ko' },
19
+ log: noop, warn: noop,
20
+ isAbortError: () => false,
21
+ playAudio: noopAsync,
22
+ sendText: noopAsync,
23
+ refreshTtsRuntimeConfig: noopAsync,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ test('createProgressHandler exposes the expected functions', () => {
29
+ const handler = createProgressHandler(makeDeps());
30
+ for (const name of [
31
+ 'ensureSmartProgressSummarizer', 'smartProgressStatusText', 'progressEmoji',
32
+ 'formatProgressText', 'sendVerboseProgressText', 'synthProgressTTS',
33
+ 'speakProgress', 'speakImmediateNotice', 'queueProgressSpeechText',
34
+ 'flushProgressSpeechBatch', 'queueVerboseProgressSpeech',
35
+ 'clearProgressSpeechBatch', 'stopProgressSpeech',
36
+ ]) {
37
+ assert.equal(typeof handler[name], 'function', `${name} is exposed`);
38
+ }
39
+ });
40
+
41
+ test('smartProgressStatusText reports off + no-key when env is empty', () => {
42
+ const prev = process.env.SMART_PROGRESS_API_KEY;
43
+ delete process.env.SMART_PROGRESS_API_KEY;
44
+ try {
45
+ const { smartProgressStatusText } = createProgressHandler(makeDeps());
46
+ const text = smartProgressStatusText();
47
+ assert.match(text, /smart-progress: off/);
48
+ assert.match(text, /no SMART_PROGRESS_API_KEY set/);
49
+ } finally {
50
+ if (prev !== undefined) process.env.SMART_PROGRESS_API_KEY = prev;
51
+ }
52
+ });
53
+
54
+ test('progressEmoji maps category keys to emoji glyphs', () => {
55
+ const { progressEmoji } = createProgressHandler(makeDeps());
56
+ // progressCategory returns null for empty event -> fallback emoji
57
+ assert.equal(progressEmoji(''), '⚙️');
58
+ });
59
+
60
+ test('queueProgressSpeechText drops empty/aborted/mismatched signals', () => {
61
+ const deps = makeDeps();
62
+ let calls = 0;
63
+ deps.playAudio = async () => { calls++; };
64
+ const { queueProgressSpeechText } = createProgressHandler(deps);
65
+ const signal = new AbortController().signal;
66
+ // No active progress signal set -> drop
67
+ queueProgressSpeechText('hello', signal, 'test');
68
+ assert.equal(calls, 0);
69
+ });
70
+
71
+ test('clearProgressSpeechBatch resets the batch buffer for the active signal', () => {
72
+ const deps = makeDeps();
73
+ const { clearProgressSpeechBatch } = createProgressHandler(deps);
74
+ deps.bridge.progressSpeechBatchSignal = 'sig';
75
+ deps.bridge.progressSpeechBatch = ['a', 'b'];
76
+ deps.bridge.progressSpeechBatchStartedAt = 12345;
77
+ clearProgressSpeechBatch('sig');
78
+ assert.equal(deps.bridge.progressSpeechBatchSignal, null);
79
+ assert.deepEqual(deps.bridge.progressSpeechBatch, []);
80
+ assert.equal(deps.bridge.progressSpeechBatchStartedAt, 0);
81
+ });
82
+
83
+ test('stopProgressSpeech is a no-op when signal does not match active', () => {
84
+ const deps = makeDeps();
85
+ deps.bridge.activeProgressSignal = 'A';
86
+ deps.bridge.speaking = true;
87
+ const { stopProgressSpeech } = createProgressHandler(deps);
88
+ stopProgressSpeech('B', 'mismatch');
89
+ // Unchanged because mismatched signal
90
+ assert.equal(deps.bridge.activeProgressSignal, 'A');
91
+ assert.equal(deps.bridge.speaking, true);
92
+ });
93
+
94
+ test('sendVerboseProgressText debounces repeated identical messages', async () => {
95
+ const deps = makeDeps();
96
+ let sent = 0;
97
+ deps.sendText = async () => { sent++; };
98
+ deps.bridge.verboseProgress = true;
99
+ const signal = new AbortController().signal;
100
+ deps.bridge.activeProgressSignal = signal;
101
+ const handler = createProgressHandler(deps);
102
+ handler.sendVerboseProgressText('test', signal);
103
+ await new Promise(r => setImmediate(r));
104
+ handler.sendVerboseProgressText('test', signal);
105
+ await new Promise(r => setImmediate(r));
106
+ // Second identical call within 2s window is debounced
107
+ assert.equal(sent, 1);
108
+ });
109
+
110
+ // --- queueVerboseProgressSpeech: batch sizing + flush -------------------
111
+
112
+ test('queueVerboseProgressSpeech buffers events into the bridge batch', () => {
113
+ // Note: the flush size is rate-adaptive — when events arrive faster than
114
+ // 6/sec (which happens in synchronous test loops), maxBatchEvents climbs
115
+ // to 5. Here we just verify the buffering side: events accumulate on the
116
+ // bridge and the signal is recorded. The flush-on-size path is exercised
117
+ // implicitly by the "ratePerSecond >= 6 path" assertion below.
118
+ const deps = makeDeps();
119
+ deps.bridge.verboseProgress = true;
120
+ const signal = new AbortController().signal;
121
+ deps.bridge.activeProgressSignal = signal;
122
+ const handler = createProgressHandler(deps);
123
+
124
+ handler.queueVerboseProgressSpeech('event one', signal);
125
+ assert.equal(deps.bridge.progressSpeechBatch.length, 1);
126
+ assert.ok(deps.bridge.progressSpeechBatchSignal === signal);
127
+ assert.ok(deps.bridge.progressSpeechBatchStartedAt > 0, 'batch start time recorded');
128
+
129
+ handler.queueVerboseProgressSpeech('event two', signal);
130
+ assert.equal(deps.bridge.progressSpeechBatch.length, 2);
131
+ });
132
+
133
+ test('queueVerboseProgressSpeech flushes on the slow-rate threshold (3 events)', () => {
134
+ const deps = makeDeps();
135
+ deps.bridge.verboseProgress = true;
136
+ const signal = new AbortController().signal;
137
+ deps.bridge.activeProgressSignal = signal;
138
+ const handler = createProgressHandler(deps);
139
+
140
+ // Force the slow-rate path by backdating progressSpeechBatchStartedAt so
141
+ // ratePerSecond falls below the 3/sec threshold (maxBatchEvents=3).
142
+ handler.queueVerboseProgressSpeech('event one', signal);
143
+ deps.bridge.progressSpeechBatchStartedAt = Date.now() - 5000; // 1 event over 5s -> 0.2/sec
144
+ handler.queueVerboseProgressSpeech('event two', signal);
145
+ // Backdate again before the third event so we stay on the slow path.
146
+ deps.bridge.progressSpeechBatchStartedAt = Date.now() - 5000;
147
+ handler.queueVerboseProgressSpeech('event three', signal);
148
+ // Third event hits maxBatchEvents=3 -> flush.
149
+ assert.equal(deps.bridge.progressSpeechBatch.length, 0, 'batch flushed on hitting slow-rate size');
150
+ assert.equal(deps.bridge.progressSpeechBatchSignal, null);
151
+ assert.equal(deps.bridge.progressSpeechBatchTimer, null);
152
+ });
153
+
154
+ test('queueVerboseProgressSpeech ignores events when signal does not match active', () => {
155
+ const deps = makeDeps();
156
+ deps.bridge.verboseProgress = true;
157
+ const activeSig = new AbortController().signal;
158
+ const otherSig = new AbortController().signal;
159
+ deps.bridge.activeProgressSignal = activeSig;
160
+ const handler = createProgressHandler(deps);
161
+ handler.queueVerboseProgressSpeech('orphan event', otherSig);
162
+ assert.equal(deps.bridge.progressSpeechBatch.length, 0, 'no batching for mismatched signal');
163
+ });
164
+
165
+ test('queueVerboseProgressSpeech is a no-op when verboseProgress disabled', () => {
166
+ const deps = makeDeps();
167
+ deps.bridge.verboseProgress = false;
168
+ const sig = new AbortController().signal;
169
+ deps.bridge.activeProgressSignal = sig;
170
+ const handler = createProgressHandler(deps);
171
+ handler.queueVerboseProgressSpeech('test', sig);
172
+ assert.equal(deps.bridge.progressSpeechBatch.length, 0);
173
+ });
174
+
175
+ test('flushProgressSpeechBatch with mismatched signal does not drain the buffer', () => {
176
+ const deps = makeDeps();
177
+ deps.bridge.progressSpeechBatch = ['queued event'];
178
+ deps.bridge.activeProgressSignal = 'A';
179
+ const handler = createProgressHandler(deps);
180
+ handler.flushProgressSpeechBatch('B', 'wrong-signal');
181
+ assert.deepEqual(deps.bridge.progressSpeechBatch, ['queued event'], 'mismatched flush is no-op');
182
+ });
183
+
184
+ test('ensureSmartProgressSummarizer memoizes and registers a summary callback', () => {
185
+ // Ensure no API key path: the summarizer is still constructed, but won't ingest.
186
+ // We don't need to interact with the real summarizer — just verify memoization.
187
+ const deps = makeDeps();
188
+ const handler = createProgressHandler(deps);
189
+ const a = handler.ensureSmartProgressSummarizer();
190
+ const b = handler.ensureSmartProgressSummarizer();
191
+ assert.equal(a, b, 'summarizer memoized on bridge');
192
+ assert.equal(deps.bridge.smartProgressSummarizer, a);
193
+ });
@@ -11,20 +11,58 @@ const CATEGORY_LABELS = {
11
11
 
12
12
  const CATEGORY_RULES = [
13
13
  { key: 'test', pattern: /(테스트|test|pytest|npm test|node --test)/i },
14
- { key: 'edit', pattern: /(파일\s*수정|수정|patch|write_file|쓰기|변경|edit)/i },
15
- { key: 'read', pattern: /(파일\s*읽기|read_file|읽기|열람)/i },
16
- { key: 'search', pattern: /(웹\s*검색|검색|web_search|search_files|찾기)/i },
17
- { key: 'terminal', pattern: /(터미널|명령|terminal|shell|실행)/i },
18
- { key: 'skill', pattern: /(스킬|skill)/i },
19
- { key: 'browser', pattern: /(브라우저|browser)/i },
20
- { key: 'tool', pattern: /(툴|도구|tool)/i },
21
- { key: 'agent', pattern: /(에이전트|agent|hermes)/i },
14
+ { key: 'edit', pattern: /(파일\s*수정|수정|patch|write_file|쓰기|변경|edit|editing\s+files?)/i },
15
+ { key: 'read', pattern: /(파일\s*읽기|read_file|읽기|열람|reading\s+files?)/i },
16
+ { key: 'search', pattern: /(웹\s*검색|검색|web_search|search_files|찾기|searching)/i },
17
+ { key: 'terminal', pattern: /(터미널|명령|terminal|shell|실행|running\s+terminal\s+commands?)/i },
18
+ { key: 'skill', pattern: /(스킬|skill|loading\s+skills?)/i },
19
+ { key: 'browser', pattern: /(브라우저|browser|checking\s+the\s+browser)/i },
20
+ { key: 'tool', pattern: /(툴|도구|tool|using\s+tools?)/i },
21
+ { key: 'agent', pattern: /(에이전트|agent|hermes|calling\s+the\s+agent|received\s+agent\s+response)/i },
22
22
  ];
23
23
 
24
24
  function labelsFor(language = 'ko') {
25
25
  return /^en/i.test(String(language || '')) ? CATEGORY_LABELS.en : CATEGORY_LABELS.ko;
26
26
  }
27
27
 
28
+ function categoryEmoji(key = 'work') {
29
+ return {
30
+ test: '🧪',
31
+ edit: '✏️',
32
+ read: '📖',
33
+ search: '🔎',
34
+ terminal: '💻',
35
+ skill: '📚',
36
+ browser: '🌐',
37
+ agent: '🤖',
38
+ tool: '🛠️',
39
+ work: '⚙️',
40
+ }[key] || '⚙️';
41
+ }
42
+
43
+ function stripProgressPrefix(text, category, { keepPaths = false, language = 'ko' } = {}) {
44
+ let detail = String(text || '')
45
+ .replace(/^VERBALCODING_PROGRESS:\s*/i, '')
46
+ .replace(/^(파일\s*읽기|파일\s*수정|웹\s*검색|터미널\s*(명령\s*)?실행|테스트\s*실행|스킬\s*사용|툴\s*사용|브라우저\s*확인|에이전트\s*(호출|처리|응답\s*수신)?|Hermes Agent\s*(호출\s*시작|응답\s*수신)?)\s*/i, '')
47
+ .replace(/^(reading\s+files?|editing\s+files?|searching(?:\s+web)?|running\s+(terminal\s+commands?|tests?)|loading\s+skills?|checking\s+the\s+browser|using\s+tools?|calling\s+the\s+agent|received\s+agent\s+response|working)\s*/i, '')
48
+ .replace(/^(read_file|write_file|patch|web_search|search_files|terminal|skill_view|tool)\s*/i, '')
49
+ .replace(/[`*_#>\[\](){}]/g, '')
50
+ .replace(/\s+/g, ' ')
51
+ .trim();
52
+ const label = category?.label || labelsFor(language).work;
53
+ if (detail.toLowerCase().startsWith(label.toLowerCase())) {
54
+ detail = detail.slice(label.length).replace(/^\s*[-:–—,;.]?\s*/u, '').trim();
55
+ }
56
+ if (!keepPaths) {
57
+ detail = detail
58
+ .replace(/\bHermes\s+Agent\b/gi, '')
59
+ .replace(/\b[a-zA-Z0-9_.\/-]+\.(mjs|js|py|md|json|txt|sh|yaml|yml)\b/g, '')
60
+ .replace(/\s+/g, ' ')
61
+ .trim();
62
+ }
63
+ return detail;
64
+ }
65
+
28
66
  export function progressCategory(event, { language = 'ko' } = {}) {
29
67
  const text = String(event || '').replace(/\s+/g, ' ').trim();
30
68
  if (!text) return null;
@@ -37,16 +75,10 @@ export function progressDetail(event, { language = 'ko' } = {}) {
37
75
  const text = String(event || '').replace(/\s+/g, ' ').trim();
38
76
  if (!text) return '';
39
77
  const category = progressCategory(text, { language });
40
- let detail = text
41
- .replace(/^VERBALCODING_PROGRESS:\s*/i, '')
42
- .replace(/^(파일\s*읽기|파일\s*수정|웹\s*검색|터미널\s*(명령\s*)?실행|테스트\s*실행|스킬\s*사용|툴\s*사용|브라우저\s*확인|에이전트\s*(호출|처리|응답\s*수신)?|Hermes Agent\s*(호출\s*시작|응답\s*수신)?)\s*/i, '')
43
- .replace(/^(read_file|write_file|patch|web_search|search_files|terminal|skill_view|tool)\s*/i, '')
44
- .replace(/[`*_#>\[\](){}]/g, '')
45
- .replace(/\b[a-zA-Z0-9_.\/-]+\.(mjs|js|py|md|json|txt|sh|yaml|yml)\b/g, '')
46
- .replace(/\s+/g, ' ')
47
- .trim();
78
+ let detail = stripProgressPrefix(text, category, { language });
48
79
  if (!detail || detail.length < 2) return category?.label || '';
49
- if (detail.length > 28) detail = detail.slice(0, 27).replace(/[\s,.;:,。]+$/u, '');
80
+ const codepoints = Array.from(detail);
81
+ if (codepoints.length > 28) detail = codepoints.slice(0, 27).join('').replace(/[\s,.;:,。]+$/u, '');
50
82
  return `${category?.label || labelsFor(language).work} ${detail}`.trim();
51
83
  }
52
84
 
@@ -54,22 +86,12 @@ export function formatProgressMessage(event, { language = 'ko' } = {}) {
54
86
  const text = String(event || '').replace(/\s+/g, ' ').trim();
55
87
  if (!text) return '';
56
88
  const category = progressCategory(text, { language });
57
- const detail = progressDetail(text, { language });
89
+ let detail = stripProgressPrefix(text, category, { language, keepPaths: true });
58
90
  const english = /^en/i.test(String(language || ''));
59
- const safeDetail = english && /\p{Script=Hangul}/u.test(detail) ? '' : detail;
60
- const body = english ? (safeDetail || category?.label || 'working') : (safeDetail || category?.label || '작업 처리');
61
- const emoji = {
62
- test: '🧪',
63
- edit: '✏️',
64
- read: '📖',
65
- search: '🔎',
66
- terminal: '💻',
67
- skill: '📚',
68
- browser: '🌐',
69
- agent: '🤖',
70
- tool: '🛠️',
71
- work: '⚙️',
72
- }[category?.key || 'work'] || '⚙️';
91
+ if (english && /\p{Script=Hangul}/u.test(detail)) detail = '';
92
+ if (!english && /[A-Za-z]/.test(detail) && !/\p{Script=Hangul}/u.test(detail)) detail = '';
93
+ const body = [category?.label || (english ? 'working' : '작업 처리'), detail].filter(Boolean).join(': ');
94
+ const emoji = categoryEmoji(category?.key || 'work');
73
95
  return `${emoji} ${body}`.trim();
74
96
  }
75
97
 
@@ -16,9 +16,18 @@ test('progressDetail preserves the meaningful task name after the generic prefix
16
16
  });
17
17
 
18
18
  test('formatProgressMessage renders intermediate text in the selected English language', () => {
19
- assert.equal(formatProgressMessage('파일 읽기 app-node/main.mjs', { language: 'en' }), '📖 reading files');
20
- assert.equal(formatProgressMessage('터미널 명령 실행 npm test', { language: 'en' }), '🧪 running tests npm test');
21
- assert.equal(formatProgressMessage('파일 수정 재시작 안내 문구 개선', { language: 'en' }), '✏️ editing files');
19
+ assert.equal(formatProgressMessage('reading files app-node/main.mjs', { language: 'en' }), '📖 reading files: app-node/main.mjs');
20
+ assert.equal(formatProgressMessage('running terminal commands npm test', { language: 'en' }), '🧪 running tests: npm test');
21
+ assert.equal(formatProgressMessage('editing files app-node/main.mjs restart notice', { language: 'en' }), '✏️ editing files: app-node/main.mjs restart notice');
22
+ });
23
+
24
+ test('English spoken progress does not repeat category phrases', () => {
25
+ assert.equal(progressDetail('calling the agent Hermes Agent', { language: 'en' }), 'calling the agent');
26
+ assert.equal(summarizeProgressEvents(['calling the agent Hermes Agent'], { language: 'en' }), 'calling the agent.');
27
+ assert.equal(summarizeProgressEvents([
28
+ 'calling the agent Hermes Agent',
29
+ 'received agent response Hermes Agent',
30
+ ], { language: 'en' }), 'calling the agent.');
22
31
  });
23
32
 
24
33
  test('agent progress events format as visible and speakable status', () => {
@@ -26,11 +26,14 @@ export function loadProjectSessions(configPath) {
26
26
 
27
27
  export function saveProjectSessions(configPath, state) {
28
28
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
29
- fs.writeFileSync(configPath, `${JSON.stringify({
29
+ const payload = `${JSON.stringify({
30
30
  version: 1,
31
31
  sessions: state.sessions || {},
32
32
  channelSessions: state.channelSessions || {},
33
- }, null, 2)}\n`, { mode: 0o600 });
33
+ }, null, 2)}\n`;
34
+ const tmpPath = `${configPath}.tmp-${process.pid}-${Date.now()}`;
35
+ fs.writeFileSync(tmpPath, payload, { mode: 0o600 });
36
+ fs.renameSync(tmpPath, configPath);
34
37
  }
35
38
 
36
39
  export function createProjectSession({ root, state, name, workdir, channelId, voiceChannelId = '', transcriptChannelId = '', mcpContext = '' }) {
@@ -16,8 +16,14 @@ import {
16
16
  slugifySessionName,
17
17
  } from './project_sessions.mjs';
18
18
 
19
+ const __tempRoots = [];
20
+ test.after(() => {
21
+ for (const root of __tempRoots) try { fs.rmSync(root, { recursive: true, force: true }); } catch {}
22
+ });
23
+
19
24
  test('project sessions map Discord text and voice channel ids to isolated Hermes session files', () => {
20
25
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-'));
26
+ __tempRoots.push(root);
21
27
  const state = loadProjectSessions(path.join(root, 'sessions.json'));
22
28
  const session = createProjectSession({ root, state, name: 'LLM Wiki', workdir: '/tmp/llm-wiki', channelId: 'text-1', voiceChannelId: 'voice-1', mcpContext: 'llm-wiki graph' });
23
29
  assert.equal(session.slug, 'llm-wiki');
@@ -30,6 +36,7 @@ test('project sessions map Discord text and voice channel ids to isolated Hermes
30
36
 
31
37
  test('project sessions persist and can be rebound to another channel', () => {
32
38
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vc-sessions-persist-'));
39
+ __tempRoots.push(root);
33
40
  const configPath = path.join(root, 'sessions.json');
34
41
  const state = loadProjectSessions(configPath);
35
42
  createProjectSession({ root, state, name: 'Other Project', workdir: root, channelId: 'a' });