agentvibes 5.15.1 → 5.15.2

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.
package/README.md CHANGED
@@ -58,7 +58,13 @@ New here? The [**Quick Start guide**](docs/quick-start.md) walks you through you
58
58
 
59
59
  ---
60
60
 
61
- ## 🆕 Know where your preview plays (v5.15.1)
61
+ ## 🆕 Fixes for the preview indicators (v5.15.2)
62
+
63
+ - **Fixed a TUI crash** — filtering/favoriting a track while a preview was playing could rebuild the list under the indicator and crash; it can't now.
64
+ - **Honest per-agent badge** — the voice picker shows **(remotely via SSH)** vs **(locally)** from your real routing, not a partial guess.
65
+ - **BMAD agents show their real names** (Mary, Winston…) even when a skill file lacks the `Name — Title` heading; plus small preview/onboarding polish.
66
+
67
+ ### v5.15.1 — Know where your preview plays
62
68
 
63
69
  - **Every preview shows where it plays** — voice and music previews now display **(locally)** or **(remotely via SSH)** right on the row you're auditioning, so you're never guessing (or hearing silence on a headless box).
64
70
  - **Preview standardized across the app** — the voice pickers (Kokoro, Piper, ElevenLabs, per-agent BMAD) and the Music page all show the same indicator; music previews now follow a project's remote receiver too.
@@ -272,7 +278,7 @@ anything (no new network calls, no behavior change) for every current install.
272
278
 
273
279
  ## About
274
280
 
275
- **AgentVibes** · v5.15.1 · Licensed under [Apache-2.0](LICENSE)
281
+ **AgentVibes** · v5.15.2 · Licensed under [Apache-2.0](LICENSE)
276
282
 
277
283
  Built by **Paul Preibisch** — [@997Fire on X](https://x.com/997Fire) · [agentvibes.org](https://agentvibes.org) · [github.com/paulpreibisch/AgentVibes](https://github.com/paulpreibisch/AgentVibes)
278
284
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # AgentVibes Release Notes
2
2
 
3
+ ## v5.15.2 — Fixes for the preview indicators
4
+
5
+ **Released:** 2026-07-22 · `npm install agentvibes@latest`
6
+
7
+ Follow-up fixes for the 5.15.1 preview work, from an adversarial code review:
8
+
9
+ - **Fixed a crash** — filtering or favoriting a track *while a preview was
10
+ playing* could rebuild the list out from under the indicator and take the whole
11
+ TUI down. It can't anymore.
12
+ - **The badge tells the truth** — the per-agent voice picker now shows
13
+ **(remotely via SSH)** vs **(locally)** based on your actual routing (provider
14
+ file or remote receiver), not a partial guess.
15
+ - **Your BMAD agents show their real names** — the Agents tab reads the persona
16
+ name (Mary, Winston, Amelia…) even on installs whose skill files don't use the
17
+ `Name — Title` heading format.
18
+ - **Smaller polish** — a remote voice-preview error no longer leaves a stuck
19
+ spinner, the onboarding "re-check" hint is translated in all languages, and the
20
+ onboarding screen keeps keyboard focus after a re-check.
21
+
3
22
  ## v5.15.1 — Know where your preview plays
4
23
 
5
24
  **Released:** 2026-07-21 · `npm install agentvibes@latest`
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "agentvibes",
4
- "version": "5.15.1",
4
+ "version": "5.15.2",
5
5
  "description": "Now your AI Agents can finally talk back! Professional TTS voice for Claude Code, Claude Desktop (via MCP), and Clawdbot with multi-provider support.",
6
6
  "homepage": "https://agentvibes.org",
7
7
  "keywords": [
@@ -53,22 +53,6 @@ export function previewRowContent(frameChar, remote, stopHint = 'Space to stop')
53
53
  return `{cyan-fg}${frameChar} Previewing {/cyan-fg}${transportBadge(remote)}{cyan-fg} (${stopHint}){/cyan-fg}`;
54
54
  }
55
55
 
56
- /**
57
- * Preview indicator APPENDED after a row's existing label (keepLabel mode).
58
- * Used where replacing the label would be jarring — e.g. the Music tab, whose
59
- * rows carry emoji: keeping the name in place avoids a perceived "jump", and
60
- * only the trailing spinner char changes per frame so blessed never re-diffs the
61
- * (wide) emoji cell.
62
- * @param {string} baseRow - the row's normal content (with its label/emoji)
63
- * @param {string} frameChar
64
- * @param {boolean} remote
65
- * @param {string} [stopHint]
66
- * @returns {string}
67
- */
68
- export function previewRowAppend(baseRow, frameChar, remote, stopHint = 'Space to stop') {
69
- return `${baseRow} {cyan-fg}${frameChar}{/cyan-fg} ${transportBadge(remote)}{cyan-fg} (${stopHint}){/cyan-fg}`;
70
- }
71
-
72
56
  /**
73
57
  * Attach an animated preview indicator to the SELECTED row of a blessed list.
74
58
  * Generalizes the Kokoro picker's row spinner so every picker behaves identically.
@@ -88,16 +72,7 @@ export function createRowSpinner(list, screen, renderItem, opts = {}) {
88
72
  const now = opts.now ?? (() => Date.now());
89
73
  const intervalMs = opts.intervalMs ?? 80;
90
74
  const minVisibleMs = opts.minVisibleMs ?? 1100;
91
- // fullRedraw: force a complete repaint (realloc) instead of a diff render.
92
- // Needed for lists containing double-width emoji (the Music tab): blessed's
93
- // diff render desyncs the terminal cursor around wide chars during rapid
94
- // in-place updates, corrupting rows — even though the internal buffer is
95
- // correct. A realloc rewrites the terminal from that correct buffer.
96
- const fullRedraw = opts.fullRedraw ?? false;
97
- // keepLabel: append the indicator after the row's existing label instead of
98
- // replacing it (avoids a perceived "jump" on emoji rows — see previewRowAppend).
99
- const keepLabel = opts.keepLabel ?? false;
100
- // isStatic: write the indicator ONCE (no animation). Needed for lists with
75
+ // isStatic: write the indicator ONCE (no animation). Used for lists with
101
76
  // double-width emoji (the Music tab): repeated in-place mutation of emoji rows
102
77
  // desyncs blessed's terminal output. One write + full-width space padding is
103
78
  // the least-fragile option there. A frozen "♪" stands in for the spinner.
@@ -110,17 +85,18 @@ export function createRowSpinner(list, screen, renderItem, opts = {}) {
110
85
  let remote = false;
111
86
  let startTs = 0;
112
87
 
113
- // full=true forces a complete repaint (realloc) used ONCE on start and stop
114
- // to fix the wide-char cursor desync from the emoji-row→spinner transition (and
115
- // stale decorations). Per-frame renders are plain diffs: the spinner row has no
116
- // wide chars, so animating it is clean AND doesn't shimmer the whole screen.
117
- function _render(full) {
118
- if (full && fullRedraw && typeof screen.realloc === 'function') { try { screen.realloc(); } catch { /* ignore */ } }
119
- screen.render();
88
+ // Guard: blessed's List.setItem dereferences this.items[i] unchecked, so a
89
+ // stale idx (e.g. the list was rebuilt shorter by a filter while a preview was
90
+ // active) would throw an uncaught TypeError and kill the whole TUI. Only touch
91
+ // a row that still exists. list.items is an array on a real list; the test stub
92
+ // uses a plain object, so treat "not an array" as always-in-range.
93
+ function _inRange(i) {
94
+ if (i < 0) return false;
95
+ return Array.isArray(list.items) ? i < list.items.length : true;
120
96
  }
121
97
 
122
- function _paint(full) {
123
- if (idx < 0) return;
98
+ function _paint() {
99
+ if (!_inRange(idx)) return;
124
100
  // Pad to the widest reliable measure so the row fully overwrites the previous
125
101
  // (longer) content. list.width may still be a percentage string pre-layout, so
126
102
  // fall back to the screen width; over-padding is clipped by the non-wrapping list.
@@ -128,19 +104,16 @@ export function createRowSpinner(list, screen, renderItem, opts = {}) {
128
104
  const sw = (screen && typeof screen.width === 'number' && screen.width > 0) ? screen.width : 0;
129
105
  const w = Math.max(lw, sw, 80);
130
106
  const spin = isStatic ? '♪' : SPIN_FRAMES[frame++ % SPIN_FRAMES.length];
131
- const content = keepLabel
132
- ? previewRowAppend(renderItem(idx), spin, remote)
133
- : previewRowContent(spin, remote);
134
- list.setItem(idx, padTaggedTo(content, w));
135
- _render(full);
107
+ list.setItem(idx, padTaggedTo(previewRowContent(spin, remote), w));
108
+ screen.render();
136
109
  }
137
110
 
138
111
  function stop() {
139
112
  if (floor) { clearTimeout(floor); floor = null; }
140
113
  if (timer) { clearInterval(timer); timer = null; }
141
- if (idx >= 0 && !isClosed()) {
114
+ if (_inRange(idx) && !isClosed()) {
142
115
  list.setItem(idx, renderItem(idx));
143
- _render(true); // one full repaint to clear the wide-char desync on restore
116
+ screen.render();
144
117
  }
145
118
  idx = -1;
146
119
  }
@@ -152,9 +125,9 @@ export function createRowSpinner(list, screen, renderItem, opts = {}) {
152
125
  frame = 0;
153
126
  remote = !!isRemote;
154
127
  startTs = now();
155
- _paint(fullRedraw); // one repaint (realloc only if fullRedraw) fixes the transition
128
+ _paint();
156
129
  if (isStatic) return; // static indicator: written once, no animation loop
157
- timer = setInterval(() => { if (isClosed()) { stop(); return; } _paint(false); }, intervalMs);
130
+ timer = setInterval(() => { if (isClosed()) { stop(); return; } _paint(); }, intervalMs);
158
131
  // A UI spinner must never keep the process alive (blessed's stdin does that
159
132
  // in the real TUI); unref so a leaked spinner can't hang node --test on exit.
160
133
  if (timer.unref) timer.unref();
@@ -21,6 +21,7 @@ import {
21
21
  } from './voices-tab.js';
22
22
  import { buildAudioEnv, detectWavPlayer, detectRemoteLlm } from '../audio-env.js';
23
23
  import { createRowSpinner } from '../preview-transport.js';
24
+ import { resolveMusicProvider } from '../music-preview.js';
24
25
  import { voicesForProvider } from '../../services/provider-voice-catalog.js';
25
26
  import { destroyList } from '../widgets/destroy-list.js';
26
27
  import { BRAND_PINK } from '../brand-colors.js';
@@ -335,7 +336,11 @@ ${_tl('bmadDesc')}
335
336
 
336
337
  // Manual re-check: after installing BMAD from a separate terminal, Enter
337
338
  // re-scans without leaving the tab. (Switching tabs also re-scans via onFocus.)
338
- onboardingBox.key(['enter'], () => { refreshDisplay(); });
339
+ // If BMAD now shows up, move focus to the roster (else keys go to the hidden box).
340
+ onboardingBox.key(['enter'], () => {
341
+ refreshDisplay();
342
+ if (_bmadDetected) { agentList.focus(); screen.render(); }
343
+ });
339
344
 
340
345
  // -------------------------------------------------------------------------
341
346
  // BMAD state — section header
@@ -1135,13 +1140,18 @@ ${_tl('bmadDesc')}
1135
1140
 
1136
1141
  const phrase = previewPhrase(voiceId);
1137
1142
  const _isWin = process.platform === 'win32' && !process.env.WSL_DISTRO_NAME;
1143
+ // Badge must match where play-tts actually routes: remote if the provider
1144
+ // file is ssh-remote/agentvibes-receiver OR a transport-config entry is
1145
+ // mode=remote. resolveMusicProvider is the shared transport resolver (both
1146
+ // signals) — don't re-derive routing here (CLAUDE.md invariant #1).
1147
+ const _isRemote = resolveMusicProvider(path.dirname(_pkgClaudeDir)).remote;
1138
1148
 
1139
1149
  if (_isWin) {
1140
1150
  // Windows: route through play-tts.ps1 (same pattern as non-Windows bash route)
1141
1151
  const playTtsScript = _hookScript('hooks-windows', 'play-tts.ps1');
1142
1152
  if (!fs.existsSync(playTtsScript)) return;
1143
1153
  _previewVoiceId = voiceId;
1144
- if (!_vpClosed) { _vpSpin.start(vpList.selected, false); }
1154
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, _isRemote); }
1145
1155
  _previewProc = spawn('powershell', [ // NOSONAR
1146
1156
  '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', playTtsScript, phrase, voiceId,
1147
1157
  ], { stdio: 'ignore', detached: false, windowsHide: true,
@@ -1176,7 +1186,7 @@ ${_tl('bmadDesc')}
1176
1186
  cwd: _projectRoot,
1177
1187
  });
1178
1188
  _previewVoiceId = voiceId;
1179
- if (!_vpClosed) { _vpSpin.start(vpList.selected, !!remoteLlm); }
1189
+ if (!_vpClosed) { _vpSpin.start(vpList.selected, _isRemote); }
1180
1190
 
1181
1191
  const _clearAfterMinDisplay = () => {
1182
1192
  if (_previewVoiceId === voiceId) {
@@ -765,8 +765,26 @@ export function createMusicTab(screen, services) {
765
765
  });
766
766
  }
767
767
 
768
+ // Stop any in-flight preview (local player, remote receiver, and the row
769
+ // spinner). Used before a list rebuild so a stale spinner index can't crash
770
+ // blessed's setItem, and so a preview can't be left "playing" invisibly.
771
+ function _stopAnyPreview() {
772
+ if (_remotePlayingTrackId) {
773
+ const mp = resolveMusicProvider(_PKG_ROOT);
774
+ if (mp.remote) _sendMusicRemote(mp, { stop: true });
775
+ _remotePlayingTrackId = null;
776
+ }
777
+ _killPlayingProcess();
778
+ _playingTrackId = null;
779
+ _trackSpin.stop();
780
+ }
781
+
768
782
  function refreshDisplay() {
769
783
  _refreshing = true;
784
+ // A rebuild (setItems) invalidates the spinner's row index and wipes its
785
+ // indicator — stop any active preview first (guarded, so a normal refresh with
786
+ // no preview is a no-op). Prevents a stale-index crash and a stuck preview.
787
+ if (_trackSpin.isActive()) _stopAnyPreview();
770
788
  const savedIdx = trackList.selected ?? 0;
771
789
 
772
790
  _allTracks = _buildAllTracks();
@@ -4261,7 +4261,7 @@ export function createSetupTab(screen, services) {
4261
4261
  _vpSpin.stopWithFloor();
4262
4262
  }
4263
4263
  });
4264
- rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; });
4264
+ rProc.on('error', () => { _previewProc = null; _previewVoiceId = null; _vpSpin.stop(); });
4265
4265
  return;
4266
4266
  }
4267
4267
 
package/src/i18n/de.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Suche:",
199
199
  readmeScrollMore: "↓ Scrollen für mehr Inhalt ↓",
200
200
  readmeNotFound: "*(Keine README.md im aktuellen Verzeichnis gefunden)*",
201
- bmadFooterNobmad: "[Tab] Tab wechseln [Q] Beenden",
201
+ bmadFooterNobmad: "[Enter] Erneut prüfen [Tab] Tab wechseln [Q] Beenden",
202
202
  bmadFooterBmad: "[↑↓/jk] Navigieren [Space] Vorschau [Enter] Konfigurieren [A] Auto-zuweisen [B] Stapel [Del] Zurücksetzen [Q] Beenden",
203
203
  };
package/src/i18n/en.js CHANGED
@@ -200,4 +200,4 @@ export default {
200
200
  readmeNotFound: "*(No README.md found in current directory)*",
201
201
  bmadFooterNobmad: "[Enter] Re-check [Tab] Switch Tab [Q] Quit",
202
202
  bmadFooterBmad: "[↑↓/jk] Navigate [Space] Preview [Enter] Configure [A] Auto-assign [B] Bulk [Del] Reset [Q] Quit",
203
- };
203
+ };
package/src/i18n/es.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Buscar:",
199
199
  readmeScrollMore: "↓ Desplázate para ver más ↓",
200
200
  readmeNotFound: "*(No se encontró README.md en el directorio actual)*",
201
- bmadFooterNobmad: "[Tab] Cambiar Pestaña [Q] Salir",
201
+ bmadFooterNobmad: "[Enter] Volver a comprobar [Tab] Cambiar Pestaña [Q] Salir",
202
202
  bmadFooterBmad: "[↑↓/jk] Navegar [Space] Previsualizar [Enter] Configurar [A] Auto-asignar [B] Masivo [Del] Restablecer [Q] Salir",
203
203
  };
package/src/i18n/fr.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Recherche:",
199
199
  readmeScrollMore: "↓ Défiler pour plus de contenu ↓",
200
200
  readmeNotFound: "*(Aucun README.md trouvé dans le répertoire actuel)*",
201
- bmadFooterNobmad: "[Tab] Changer d'onglet [Q] Quitter",
201
+ bmadFooterNobmad: "[Enter] Revérifier [Tab] Changer d'onglet [Q] Quitter",
202
202
  bmadFooterBmad: "[↑↓/jk] Naviguer [Space] Aperçu [Enter] Configurer [A] Auto-assigner [B] Lot [Del] Réinitialiser [Q] Quitter",
203
203
  };
package/src/i18n/hi.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "खोजें:",
199
199
  readmeScrollMore: "↓ अधिक सामग्री के लिए स्क्रॉल करें ↓",
200
200
  readmeNotFound: "*(वर्तमान निर्देशिका में README.md नहीं मिला)*",
201
- bmadFooterNobmad: "[Tab] टैब बदलें [Q] बाहर",
201
+ bmadFooterNobmad: "[Enter] फिर से जाँचें [Tab] टैब बदलें [Q] बाहर",
202
202
  bmadFooterBmad: "[↑↓/jk] नेविगेट [Space] प्रीव्यू [Enter] कॉन्फ़िगर [A] ऑटो-असाइन [B] बल्क [Del] रीसेट [Q] बाहर",
203
203
  };
package/src/i18n/ja.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "検索:",
199
199
  readmeScrollMore: "↓ スクロールして続きを見る ↓",
200
200
  readmeNotFound: "*(現在のディレクトリにREADME.mdが見つかりません)*",
201
- bmadFooterNobmad: "[Tab] タブ切替 [Q] 終了",
201
+ bmadFooterNobmad: "[Enter] 再確認 [Tab] タブ切替 [Q] 終了",
202
202
  bmadFooterBmad: "[↑↓/jk] 移動 [Space] プレビュー [Enter] 設定 [A] 自動割当 [B] 一括 [Del] リセット [Q] 終了",
203
203
  };
package/src/i18n/ko.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "검색:",
199
199
  readmeScrollMore: "↓ 아래로 스크롤하여 더 보기 ↓",
200
200
  readmeNotFound: "*(현재 디렉토리에서 README.md를 찾을 수 없습니다)*",
201
- bmadFooterNobmad: "[Tab] 탭 전환 [Q] 나가기",
201
+ bmadFooterNobmad: "[Enter] 다시 확인 [Tab] 탭 전환 [Q] 나가기",
202
202
  bmadFooterBmad: "[↑↓/jk] 탐색 [Space] 미리보기 [Enter] 설정 [A] 자동배정 [B] 일괄 [Del] 초기화 [Q] 나가기",
203
203
  };
package/src/i18n/pt.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "Buscar:",
199
199
  readmeScrollMore: "↓ Role para mais conteúdo ↓",
200
200
  readmeNotFound: "*(Nenhum README.md encontrado no diretório atual)*",
201
- bmadFooterNobmad: "[Tab] Mudar Aba [Q] Sair",
201
+ bmadFooterNobmad: "[Enter] Verificar novamente [Tab] Mudar Aba [Q] Sair",
202
202
  bmadFooterBmad: "[↑↓/jk] Navegar [Space] Visualizar [Enter] Configurar [A] Auto-atribuir [B] Lote [Del] Redefinir [Q] Sair",
203
203
  };
package/src/i18n/zh-CN.js CHANGED
@@ -198,6 +198,6 @@ export default {
198
198
  helpSearchLabel: "搜索:",
199
199
  readmeScrollMore: "↓ 向下滚动查看更多 ↓",
200
200
  readmeNotFound: "*(当前目录中未找到README.md)*",
201
- bmadFooterNobmad: "[Tab] 切换标签 [Q] 退出",
201
+ bmadFooterNobmad: "[Enter] 重新检查 [Tab] 切换标签 [Q] 退出",
202
202
  bmadFooterBmad: "[↑↓/jk] 导航 [Space] 预览 [Enter] 配置 [A] 自动分配 [B] 批量 [Del] 重置 [Q] 退出",
203
203
  };
@@ -228,18 +228,31 @@ function _findBmadAgentSkills(root) {
228
228
  * heading ("# Mary — Business Analyst") for the persona name + title. Falls back
229
229
  * to a title-cased id when the heading is missing/unreadable.
230
230
  */
231
+ // Generic section words that a bare "# Word" heading might be (not a persona).
232
+ const _GENERIC_HEADING = /^(overview|about|usage|conventions|activation|role|purpose|description|instructions)$/i;
233
+
231
234
  function _agentFromSkill(id, skillDir) {
232
235
  let displayName = _titleCaseId(id);
233
236
  let title = '';
234
237
  try {
235
238
  const md = fs.readFileSync(path.resolve(skillDir, 'SKILL.md'), 'utf8');
236
- // Persona heading format: "# Mary — Business Analyst" (name <dash> title).
237
- // Require the spaced dash so a generic section heading like "# Overview" is
238
- // skipped and we fall back to the title-cased id instead of mislabeling.
239
- const m = md.match(/^#\s+([^\n]+?)\s+[—–-]\s+([^\n]+?)\s*$/m); // em / en / hyphen
240
- if (m) {
241
- displayName = m[1].trim() || displayName;
242
- title = m[2].trim();
239
+ // 1) Preferred: "# Mary — Business Analyst" (name <dash> title).
240
+ const dash = md.match(/^#\s+([^\n]+?)\s+[—–-]\s+([^\n]+?)\s*$/m); // em / en / hyphen
241
+ if (dash) {
242
+ displayName = dash[1].trim() || displayName;
243
+ title = dash[2].trim();
244
+ } else {
245
+ // 2) Frontmatter description often names the persona: "...talk to Mary...".
246
+ const desc = md.match(/^description:\s*(.+)$/mi);
247
+ const talk = desc && desc[1].match(/talk to ([A-Z][\w'’-]+)/);
248
+ if (talk) {
249
+ displayName = talk[1];
250
+ } else {
251
+ // 3) A bare "# Mary" heading — but skip generic section headings so a
252
+ // skill without a persona heading (e.g. "# Overview") falls back to the id.
253
+ const h1 = md.match(/^#\s+([A-Z][\w'’-]*)\s*$/m);
254
+ if (h1 && !_GENERIC_HEADING.test(h1[1])) displayName = h1[1];
255
+ }
243
256
  }
244
257
  } catch { /* use derived displayName */ }
245
258
  return { id, displayName, title, icon: '', module: 'bmm' };