agentvibes 5.13.1 → 5.15.0

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 (84) hide show
  1. package/.claude/audio/ui/CREDITS.txt +16 -16
  2. package/.claude/commands/agent-vibes-bmad-voices.md +117 -117
  3. package/.claude/commands/agent-vibes-rdp.md +24 -24
  4. package/.claude/config/audio-effects.cfg.sample +52 -52
  5. package/.claude/docs/TERMUX_SETUP.md +408 -408
  6. package/.claude/github-star-reminder.txt +1 -1
  7. package/.claude/hooks/kokoro-server.py +219 -219
  8. package/.claude/hooks/kokoro-tts.py +141 -141
  9. package/.claude/hooks/party-stage-roster.py +328 -328
  10. package/.claude/hooks/play-tts-kokoro.sh +7 -2
  11. package/.claude/hooks/play-tts-ssh-remote.sh +38 -3
  12. package/.claude/hooks/requirements.txt +6 -6
  13. package/.claude/hooks/soprano-gradio-synth.py +139 -139
  14. package/.claude/hooks/translator.py +237 -237
  15. package/.claude/hooks-windows/agentvibes-session-id.ps1 +83 -0
  16. package/.claude/hooks-windows/kokoro-server.py +219 -219
  17. package/.claude/hooks-windows/kokoro-tts.py +107 -107
  18. package/.claude/hooks-windows/play-tts.ps1 +112 -1
  19. package/.claude/hooks-windows/session-start-tts.ps1 +36 -0
  20. package/.claude/hooks-windows/soprano-gradio-synth.py +153 -153
  21. package/.claude/verbosity.txt +1 -1
  22. package/.clawdbot/README.md +105 -105
  23. package/CLAUDE.md +84 -176
  24. package/README.md +35 -2
  25. package/RELEASE_NOTES.md +139 -0
  26. package/WINDOWS-SETUP.md +208 -208
  27. package/bin/agent-vibes +39 -39
  28. package/bin/mcp-server.js +121 -121
  29. package/bin/test-bmad-pr +78 -78
  30. package/mcp-server/QUICK_START.md +203 -203
  31. package/mcp-server/README.md +345 -345
  32. package/mcp-server/examples/claude_desktop_config.json +11 -11
  33. package/mcp-server/examples/claude_desktop_config_piper.json +9 -9
  34. package/mcp-server/examples/custom_instructions.md +169 -169
  35. package/mcp-server/install-deps.js +12 -2
  36. package/mcp-server/server.py +2111 -2085
  37. package/mcp-server/test_mcp_correctness.py +504 -504
  38. package/mcp-server/test_windows_script_parity.py +339 -339
  39. package/package.json +6 -3
  40. package/setup-ssh-receiver.ps1 +259 -0
  41. package/setup-windows.ps1 +39 -29
  42. package/src/cli/list-personalities.js +110 -110
  43. package/src/commands/bmad-voices.js +394 -394
  44. package/src/console/brand-colors.js +13 -13
  45. package/src/console/constants/personalities.js +44 -44
  46. package/src/console/tabs/agents-tab.js +14 -4
  47. package/src/console/tabs/help-tab.js +314 -314
  48. package/src/console/tabs/readme-tab.js +272 -272
  49. package/src/console/tabs/settings-tab.js +31 -6
  50. package/src/console/tabs/setup-tab.js +154 -32
  51. package/src/console/tabs/voices-tab.js +48 -6
  52. package/src/console/widgets/destroy-list.js +25 -25
  53. package/src/console/widgets/notice.js +55 -55
  54. package/src/installer/language-screen.js +31 -31
  55. package/src/installer/music-file-input.js +304 -304
  56. package/src/installer.js +98 -37
  57. package/src/services/language-service.js +47 -47
  58. package/src/services/llm-provider-service.js +4 -1
  59. package/src/services/provider-voice-catalog.js +9 -5
  60. package/src/utils/audio-format-validator.js +277 -277
  61. package/src/utils/dependency-checker.js +469 -469
  62. package/src/utils/file-ownership-verifier.js +358 -358
  63. package/src/utils/music-file-validator.js +285 -285
  64. package/src/utils/preview-list-prompt.js +144 -144
  65. package/src/utils/secure-music-storage.js +412 -412
  66. package/templates/agentvibes-receiver.ps1 +311 -0
  67. package/templates/agentvibes-receiver.sh +297 -297
  68. package/voice-assignments.json +8244 -8244
  69. package/.agentvibes/config.json +0 -17
  70. package/.agentvibes/install-manifest.json +0 -362
  71. package/.claude/config/audio-effects.cfg +0 -7
  72. package/.claude/config/audio-effects.cfg.bak-kokoro +0 -7
  73. package/.claude/config/background-music-enabled.txt +0 -1
  74. package/.claude/config/background-music-position.txt +0 -27
  75. package/.claude/config/background-music-volume.txt +0 -1
  76. package/.claude/config/background-music.cfg +0 -1
  77. package/.claude/config/background-music.txt +0 -1
  78. package/.claude/config/language.txt +0 -1
  79. package/.claude/config/personality.txt +0 -1
  80. package/.claude/config/reverb-level.txt +0 -1
  81. package/.claude/config/tts-speech-rate.txt +0 -1
  82. package/.claude/config/tts-verbosity.txt +0 -1
  83. package/.claude/hooks-windows/audio-cache-utils.ps1.user.bak +0 -119
  84. package/.claude/hooks-windows/soprano-gradio-synth.py.user.bak +0 -153
@@ -1,328 +1,328 @@
1
- #!/usr/bin/env python3
2
- # /// script
3
- # requires-python = ">=3.11"
4
- # ///
5
- """Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
6
-
7
- A receiver normally learns an avatar only when it speaks (one /speak event per
8
- line). This doorbell stages the WHOLE cast up front: it resolves the active
9
- party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
10
- its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
11
- endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
12
- can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
13
- down/absent one) simply never hears the doorbell and the party runs unchanged.
14
-
15
- Session key
16
- -----------
17
- The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
18
- ``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
19
- way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
20
- ``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
21
- on its OWN session, distinct from the plain-TTS session for the same project.
22
-
23
- Open-cast vs fixed-cast (the forward-compat contract)
24
- -----------------------------------------------------
25
- * Fixed-cast (members present): POST
26
- {"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
27
- * Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
28
- members, only a freeform ``scene`` theme): POST
29
- {"session","project","roster":[...anchors if any...],
30
- "openCast":true,"room":{"id","name","scene"}}
31
- Open-cast rooms are broadcast rather than skipped, so a receiver that
32
- understands the room theme has everything it needs to render it.
33
-
34
- ``project`` is sent as a back-compat alias of ``session`` (current receivers key
35
- on ``project``).
36
-
37
- Best-effort contract
38
- --------------------
39
- Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
40
- timeout -- prints a benign note to stderr and returns 0. A down or absent
41
- receiver must NEVER block or fail the party.
42
-
43
- party-stage-roster.py --project-root P [--skill S] [--party ID]
44
- [--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
45
- """
46
-
47
- import argparse
48
- import json
49
- import os
50
- import re
51
- import subprocess
52
- import sys
53
- import urllib.request
54
- import urllib.error
55
- from pathlib import Path
56
-
57
-
58
- def _resolve_roster(project_root: Path, skill_root: Path, party):
59
- """Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
60
- script = skill_root / "scripts" / "resolve_party.py"
61
- cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
62
- if party:
63
- cmd += ["--party", party]
64
- try:
65
- # encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
66
- # which crashes on the non-Latin icon bytes the resolver can emit.
67
- # PYTHONUTF8 forces the child to write UTF-8 rather than crash.
68
- child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
69
- out = subprocess.run(cmd, capture_output=True, text=True,
70
- encoding="utf-8", errors="replace",
71
- env=child_env, timeout=60)
72
- except (OSError, subprocess.SubprocessError) as e:
73
- sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
74
- return None
75
- if out.returncode != 0 or not out.stdout.strip():
76
- sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
77
- return None
78
- try:
79
- return json.loads(out.stdout)
80
- except json.JSONDecodeError:
81
- sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
82
- return None
83
-
84
-
85
- def _home_dir() -> Path:
86
- """User home, resilient to the launch environment.
87
-
88
- os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
89
- HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
90
- hand these scripts. Check the vars actually present, Windows-then-POSIX.
91
- """
92
- for var in ("USERPROFILE", "HOME"):
93
- v = os.environ.get(var)
94
- if v:
95
- return Path(v)
96
- hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
97
- if hd and hp:
98
- return Path(hd + hp)
99
- return Path(os.path.expanduser("~"))
100
-
101
-
102
- def _find_skill_root(project_root: Path):
103
- """Locate the installed bmad-party-mode skill dir (project-local, else global).
104
-
105
- Returns a Path whose scripts/resolve_party.py exists, or None.
106
- """
107
- candidates = [
108
- project_root / ".claude" / "skills" / "bmad-party-mode",
109
- _home_dir() / ".claude" / "skills" / "bmad-party-mode",
110
- ]
111
- for c in candidates:
112
- if (c / "scripts" / "resolve_party.py").exists():
113
- return c
114
- return None
115
-
116
-
117
- def _load_json_map(name: str) -> dict:
118
- """Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
119
- path = _home_dir() / ".agentvibes" / name
120
- try:
121
- with path.open(encoding="utf-8") as f:
122
- data = json.load(f)
123
- except (OSError, json.JSONDecodeError):
124
- return {}
125
- return data if isinstance(data, dict) else {}
126
-
127
-
128
- def _voice_for(code: str, vmap: dict) -> str:
129
- """Saved voice string for an agent code, or '' if none.
130
-
131
- Prefers the richer agents[code].voice; falls back to the flat
132
- voiceMap[code] (older shape). Both key by the canonical agent code.
133
- """
134
- agents = vmap.get("agents") or {}
135
- entry = agents.get(code)
136
- if isinstance(entry, dict) and entry.get("voice"):
137
- return str(entry["voice"])
138
- flat = vmap.get("voiceMap") or {}
139
- if isinstance(flat.get(code), str):
140
- return flat[code]
141
- return ""
142
-
143
-
144
- def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
145
- """Saved avatarId for an agent code, or '' if none.
146
-
147
- The dedicated avatar map wins; the voice map's agents[id].avatarId is a
148
- legacy fallback (honoured if present).
149
- """
150
- ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
151
- entry = ag.get(code)
152
- if isinstance(entry, dict) and entry.get("avatarId"):
153
- return str(entry["avatarId"])
154
- agents = vmap.get("agents") or {}
155
- ventry = agents.get(code)
156
- if isinstance(ventry, dict) and ventry.get("avatarId"):
157
- return str(ventry["avatarId"])
158
- return ""
159
-
160
-
161
- def _session_key(project_root: Path) -> str:
162
- """Canonical routing SESSION id -- the same one the TTS forward paths send.
163
-
164
- Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
165
- CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
166
- slugified so the id is a safe JSON string and a stable receiver map key.
167
- """
168
- cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
169
- base = Path(cpd) if cpd else project_root
170
- name = base.name or "unknown"
171
- # Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
172
- # to '-', then trim leading/trailing dashes.
173
- name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
174
- return name or "unknown"
175
-
176
-
177
- def _active_party(session_key: str, explicit):
178
- """Resolve which room to stage.
179
-
180
- Explicit --party wins. Otherwise read the AgentVibes-side active-room state
181
- file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
182
- by party-set-room.sh). If neither is present, return None so the DEFAULT
183
- party resolves (the common installed-agent room).
184
- """
185
- if explicit:
186
- return explicit
187
- path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
188
- try:
189
- with path.open(encoding="utf-8") as f:
190
- data = json.load(f)
191
- except (OSError, json.JSONDecodeError):
192
- return None
193
- if isinstance(data, dict):
194
- group = data.get("group")
195
- if isinstance(group, str) and group.strip():
196
- return group.strip()
197
- return None
198
-
199
-
200
- def build_roster(resolved: dict, vmap: dict, amap: dict):
201
- """Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
202
- members = resolved.get("members") or []
203
- roster, skipped = [], []
204
- for m in members:
205
- code = m.get("code") or ""
206
- name = m.get("name") or code or "Agent"
207
- voice = _voice_for(code, vmap)
208
- if not voice:
209
- # Without a voice string the member can't key-match a later /speak
210
- # event, so staging it would orphan an avatar -- skip it.
211
- skipped.append(name)
212
- continue
213
- member = {"name": name, "agentId": code, "voice": voice}
214
- title = (m.get("title") or "").strip()
215
- if title:
216
- member["role"] = title
217
- avatar = _avatar_for(code, vmap, amap)
218
- if avatar:
219
- member["avatarId"] = avatar
220
- roster.append(member)
221
- return roster, skipped
222
-
223
-
224
- def post_payload(url: str, payload: dict):
225
- """POST the payload to /stage-roster. Returns (ok, message)."""
226
- body = json.dumps(payload).encode("utf-8")
227
- base = url.rstrip("/")
228
- req = urllib.request.Request(
229
- base + "/stage-roster",
230
- data=body,
231
- headers={"Content-Type": "application/json", "Origin": base},
232
- method="POST",
233
- )
234
- try:
235
- with urllib.request.urlopen(req, timeout=10) as resp:
236
- return True, resp.read().decode("utf-8", "replace")
237
- except urllib.error.HTTPError as e:
238
- return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
239
- except (urllib.error.URLError, OSError) as e:
240
- # Receiver not running / unreachable -- benign; the party runs regardless.
241
- return False, f"could not reach receiver ({e}); is a receiver up?"
242
-
243
-
244
- def _default_url() -> str:
245
- port = (os.environ.get("AGENTVIBES_TH_PORT")
246
- or os.environ.get("TALKING_HEAD_PORT")
247
- or "3747").strip()
248
- if not port.isdigit():
249
- port = "3747"
250
- return f"http://127.0.0.1:{port}"
251
-
252
-
253
- def main() -> int:
254
- ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
255
- ap.add_argument("--project-root", required=True)
256
- ap.add_argument("--skill", default=None,
257
- help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
258
- ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
259
- ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
260
- default="bmad-party-mode",
261
- help="Append '-<suffix>' to the session key so the party stages on its "
262
- "OWN session (default: bmad-party-mode).")
263
- ap.add_argument("--url", default=None)
264
- args = ap.parse_args()
265
-
266
- project_root = Path(args.project_root).resolve()
267
-
268
- skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
269
- if skill_root is None:
270
- sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
271
- return 0
272
-
273
- session = _session_key(project_root)
274
- party = _active_party(session, args.party)
275
-
276
- resolved = _resolve_roster(project_root, skill_root, party)
277
- if resolved is None:
278
- return 0 # resolver failure already warned; never break the party
279
-
280
- suffix = (args.session_suffix or "").strip().strip("-")
281
- session_key = f"{session}-{suffix}" if suffix else session
282
-
283
- vmap = _load_json_map("bmad-voice-map.json")
284
- amap = _load_json_map("bmad-avatar-map.json")
285
- roster, skipped = build_roster(resolved, vmap, amap)
286
- if skipped:
287
- sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
288
-
289
- open_cast = bool(resolved.get("open_cast"))
290
-
291
- if open_cast:
292
- # Broadcast open-cast rooms rather than skipping them: the room theme is
293
- # all a receiver needs to render the scene.
294
- room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
295
- scene = resolved.get("scene")
296
- if scene:
297
- room["scene"] = scene
298
- payload = {"session": session_key, "project": session_key,
299
- "roster": roster, "openCast": True, "room": room}
300
- else:
301
- if not roster:
302
- sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
303
- return 0
304
- payload = {"session": session_key, "project": session_key, "roster": roster}
305
-
306
- # Test hook: capture the exact payload to a file instead of hitting the
307
- # network, so the hermetic bats suite can assert the wire shape.
308
- capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
309
- if capture:
310
- try:
311
- Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
312
- except OSError as e:
313
- sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
314
- print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
315
- return 0
316
-
317
- url = args.url or _default_url()
318
- ok, msg = post_payload(url, payload)
319
- kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
320
- if ok:
321
- print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
322
- else:
323
- print(f"stage-roster: {msg}")
324
- return 0 # benign: a down receiver must never block the party
325
-
326
-
327
- if __name__ == "__main__":
328
- sys.exit(main())
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Broadcast a BMAD party's cast to an AgentVibes receiver (best-effort doorbell).
6
+
7
+ A receiver normally learns an avatar only when it speaks (one /speak event per
8
+ line). This doorbell stages the WHOLE cast up front: it resolves the active
9
+ party roster (via BMAD's read-only ``resolve_party.py``), joins each member to
10
+ its saved voice/avatar, and POSTs the result to a receiver's ``/stage-roster``
11
+ endpoint keyed by the SESSION id. Any receiver that implements ``/stage-roster``
12
+ can then paint the labelled, voiced cast at once; a receiver that doesn't (or a
13
+ down/absent one) simply never hears the doorbell and the party runs unchanged.
14
+
15
+ Session key
16
+ -----------
17
+ The broadcast is keyed by the canonical routing SESSION id -- ``basename`` of
18
+ ``CLAUDE_PROJECT_DIR`` (falling back to ``--project-root``), slugified the same
19
+ way ``.claude/hooks/session-id.sh :: av_session_id`` slugifies it -- plus the
20
+ ``--session-suffix`` (default ``bmad-party-mode``). The suffix stages the party
21
+ on its OWN session, distinct from the plain-TTS session for the same project.
22
+
23
+ Open-cast vs fixed-cast (the forward-compat contract)
24
+ -----------------------------------------------------
25
+ * Fixed-cast (members present): POST
26
+ {"session","project","roster":[{name,agentId,voice,role?,avatarId?}]}
27
+ * Open-cast (resolve_party sets ``open_cast`` when a room lists no fixed
28
+ members, only a freeform ``scene`` theme): POST
29
+ {"session","project","roster":[...anchors if any...],
30
+ "openCast":true,"room":{"id","name","scene"}}
31
+ Open-cast rooms are broadcast rather than skipped, so a receiver that
32
+ understands the room theme has everything it needs to render it.
33
+
34
+ ``project`` is sent as a back-compat alias of ``session`` (current receivers key
35
+ on ``project``).
36
+
37
+ Best-effort contract
38
+ --------------------
39
+ Stdlib only (urllib). Any failure -- resolver error, connection refused, 404,
40
+ timeout -- prints a benign note to stderr and returns 0. A down or absent
41
+ receiver must NEVER block or fail the party.
42
+
43
+ party-stage-roster.py --project-root P [--skill S] [--party ID]
44
+ [--session-suffix bmad-party-mode] [--url http://127.0.0.1:3747]
45
+ """
46
+
47
+ import argparse
48
+ import json
49
+ import os
50
+ import re
51
+ import subprocess
52
+ import sys
53
+ import urllib.request
54
+ import urllib.error
55
+ from pathlib import Path
56
+
57
+
58
+ def _resolve_roster(project_root: Path, skill_root: Path, party):
59
+ """Run BMAD's resolve_party.py (READ-ONLY) and return its parsed JSON, or None."""
60
+ script = skill_root / "scripts" / "resolve_party.py"
61
+ cmd = [sys.executable, str(script), "--project-root", str(project_root), "--skill", str(skill_root)]
62
+ if party:
63
+ cmd += ["--party", party]
64
+ try:
65
+ # encoding+errors are REQUIRED on Windows: text=True defaults to cp1252,
66
+ # which crashes on the non-Latin icon bytes the resolver can emit.
67
+ # PYTHONUTF8 forces the child to write UTF-8 rather than crash.
68
+ child_env = {**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"}
69
+ out = subprocess.run(cmd, capture_output=True, text=True,
70
+ encoding="utf-8", errors="replace",
71
+ env=child_env, timeout=60)
72
+ except (OSError, subprocess.SubprocessError) as e:
73
+ sys.stderr.write(f"stage-roster: resolve_party failed to run: {e}\n")
74
+ return None
75
+ if out.returncode != 0 or not out.stdout.strip():
76
+ sys.stderr.write("stage-roster: resolve_party returned nothing usable\n")
77
+ return None
78
+ try:
79
+ return json.loads(out.stdout)
80
+ except json.JSONDecodeError:
81
+ sys.stderr.write("stage-roster: resolve_party output was not JSON\n")
82
+ return None
83
+
84
+
85
+ def _home_dir() -> Path:
86
+ """User home, resilient to the launch environment.
87
+
88
+ os.path.expanduser("~") returns "~" unchanged when USERPROFILE and
89
+ HOMEDRIVE/HOMEPATH are all unset -- which is exactly the env some launchers
90
+ hand these scripts. Check the vars actually present, Windows-then-POSIX.
91
+ """
92
+ for var in ("USERPROFILE", "HOME"):
93
+ v = os.environ.get(var)
94
+ if v:
95
+ return Path(v)
96
+ hd, hp = os.environ.get("HOMEDRIVE"), os.environ.get("HOMEPATH")
97
+ if hd and hp:
98
+ return Path(hd + hp)
99
+ return Path(os.path.expanduser("~"))
100
+
101
+
102
+ def _find_skill_root(project_root: Path):
103
+ """Locate the installed bmad-party-mode skill dir (project-local, else global).
104
+
105
+ Returns a Path whose scripts/resolve_party.py exists, or None.
106
+ """
107
+ candidates = [
108
+ project_root / ".claude" / "skills" / "bmad-party-mode",
109
+ _home_dir() / ".claude" / "skills" / "bmad-party-mode",
110
+ ]
111
+ for c in candidates:
112
+ if (c / "scripts" / "resolve_party.py").exists():
113
+ return c
114
+ return None
115
+
116
+
117
+ def _load_json_map(name: str) -> dict:
118
+ """Read a ~/.agentvibes/<name> JSON map. {} if absent or unreadable."""
119
+ path = _home_dir() / ".agentvibes" / name
120
+ try:
121
+ with path.open(encoding="utf-8") as f:
122
+ data = json.load(f)
123
+ except (OSError, json.JSONDecodeError):
124
+ return {}
125
+ return data if isinstance(data, dict) else {}
126
+
127
+
128
+ def _voice_for(code: str, vmap: dict) -> str:
129
+ """Saved voice string for an agent code, or '' if none.
130
+
131
+ Prefers the richer agents[code].voice; falls back to the flat
132
+ voiceMap[code] (older shape). Both key by the canonical agent code.
133
+ """
134
+ agents = vmap.get("agents") or {}
135
+ entry = agents.get(code)
136
+ if isinstance(entry, dict) and entry.get("voice"):
137
+ return str(entry["voice"])
138
+ flat = vmap.get("voiceMap") or {}
139
+ if isinstance(flat.get(code), str):
140
+ return flat[code]
141
+ return ""
142
+
143
+
144
+ def _avatar_for(code: str, vmap: dict, amap: dict) -> str:
145
+ """Saved avatarId for an agent code, or '' if none.
146
+
147
+ The dedicated avatar map wins; the voice map's agents[id].avatarId is a
148
+ legacy fallback (honoured if present).
149
+ """
150
+ ag = (amap.get("agents") or {}) if isinstance(amap, dict) else {}
151
+ entry = ag.get(code)
152
+ if isinstance(entry, dict) and entry.get("avatarId"):
153
+ return str(entry["avatarId"])
154
+ agents = vmap.get("agents") or {}
155
+ ventry = agents.get(code)
156
+ if isinstance(ventry, dict) and ventry.get("avatarId"):
157
+ return str(ventry["avatarId"])
158
+ return ""
159
+
160
+
161
+ def _session_key(project_root: Path) -> str:
162
+ """Canonical routing SESSION id -- the same one the TTS forward paths send.
163
+
164
+ Mirrors .claude/hooks/session-id.sh :: av_session_id: basename of
165
+ CLAUDE_PROJECT_DIR (the real user project) when set, else --project-root,
166
+ slugified so the id is a safe JSON string and a stable receiver map key.
167
+ """
168
+ cpd = os.environ.get("CLAUDE_PROJECT_DIR", "").strip()
169
+ base = Path(cpd) if cpd else project_root
170
+ name = base.name or "unknown"
171
+ # Slugify to match av_session_id: collapse anything outside [A-Za-z0-9._-]
172
+ # to '-', then trim leading/trailing dashes.
173
+ name = re.sub(r"[^A-Za-z0-9._-]", "-", name).strip("-")
174
+ return name or "unknown"
175
+
176
+
177
+ def _active_party(session_key: str, explicit):
178
+ """Resolve which room to stage.
179
+
180
+ Explicit --party wins. Otherwise read the AgentVibes-side active-room state
181
+ file ~/.agentvibes/party-active-<sessionid>.json = {"group": "<id>"} (written
182
+ by party-set-room.sh). If neither is present, return None so the DEFAULT
183
+ party resolves (the common installed-agent room).
184
+ """
185
+ if explicit:
186
+ return explicit
187
+ path = _home_dir() / ".agentvibes" / f"party-active-{session_key}.json"
188
+ try:
189
+ with path.open(encoding="utf-8") as f:
190
+ data = json.load(f)
191
+ except (OSError, json.JSONDecodeError):
192
+ return None
193
+ if isinstance(data, dict):
194
+ group = data.get("group")
195
+ if isinstance(group, str) and group.strip():
196
+ return group.strip()
197
+ return None
198
+
199
+
200
+ def build_roster(resolved: dict, vmap: dict, amap: dict):
201
+ """Join resolver members to voices/avatars. Returns (roster, skipped_names)."""
202
+ members = resolved.get("members") or []
203
+ roster, skipped = [], []
204
+ for m in members:
205
+ code = m.get("code") or ""
206
+ name = m.get("name") or code or "Agent"
207
+ voice = _voice_for(code, vmap)
208
+ if not voice:
209
+ # Without a voice string the member can't key-match a later /speak
210
+ # event, so staging it would orphan an avatar -- skip it.
211
+ skipped.append(name)
212
+ continue
213
+ member = {"name": name, "agentId": code, "voice": voice}
214
+ title = (m.get("title") or "").strip()
215
+ if title:
216
+ member["role"] = title
217
+ avatar = _avatar_for(code, vmap, amap)
218
+ if avatar:
219
+ member["avatarId"] = avatar
220
+ roster.append(member)
221
+ return roster, skipped
222
+
223
+
224
+ def post_payload(url: str, payload: dict):
225
+ """POST the payload to /stage-roster. Returns (ok, message)."""
226
+ body = json.dumps(payload).encode("utf-8")
227
+ base = url.rstrip("/")
228
+ req = urllib.request.Request(
229
+ base + "/stage-roster",
230
+ data=body,
231
+ headers={"Content-Type": "application/json", "Origin": base},
232
+ method="POST",
233
+ )
234
+ try:
235
+ with urllib.request.urlopen(req, timeout=10) as resp:
236
+ return True, resp.read().decode("utf-8", "replace")
237
+ except urllib.error.HTTPError as e:
238
+ return False, f"HTTP {e.code}: {e.read().decode('utf-8', 'replace')[:200]}"
239
+ except (urllib.error.URLError, OSError) as e:
240
+ # Receiver not running / unreachable -- benign; the party runs regardless.
241
+ return False, f"could not reach receiver ({e}); is a receiver up?"
242
+
243
+
244
+ def _default_url() -> str:
245
+ port = (os.environ.get("AGENTVIBES_TH_PORT")
246
+ or os.environ.get("TALKING_HEAD_PORT")
247
+ or "3747").strip()
248
+ if not port.isdigit():
249
+ port = "3747"
250
+ return f"http://127.0.0.1:{port}"
251
+
252
+
253
+ def main() -> int:
254
+ ap = argparse.ArgumentParser(description="Broadcast a BMAD party cast to an AgentVibes receiver.")
255
+ ap.add_argument("--project-root", required=True)
256
+ ap.add_argument("--skill", default=None,
257
+ help="Path to the bmad-party-mode skill dir (auto-located if omitted)")
258
+ ap.add_argument("--party", default=None, help="Explicit group id (wins over the active-room file)")
259
+ ap.add_argument("--session-suffix", "--coin-suffix", dest="session_suffix",
260
+ default="bmad-party-mode",
261
+ help="Append '-<suffix>' to the session key so the party stages on its "
262
+ "OWN session (default: bmad-party-mode).")
263
+ ap.add_argument("--url", default=None)
264
+ args = ap.parse_args()
265
+
266
+ project_root = Path(args.project_root).resolve()
267
+
268
+ skill_root = Path(args.skill).resolve() if args.skill else _find_skill_root(project_root)
269
+ if skill_root is None:
270
+ sys.stderr.write("stage-roster: bmad-party-mode skill not found -- nothing to stage.\n")
271
+ return 0
272
+
273
+ session = _session_key(project_root)
274
+ party = _active_party(session, args.party)
275
+
276
+ resolved = _resolve_roster(project_root, skill_root, party)
277
+ if resolved is None:
278
+ return 0 # resolver failure already warned; never break the party
279
+
280
+ suffix = (args.session_suffix or "").strip().strip("-")
281
+ session_key = f"{session}-{suffix}" if suffix else session
282
+
283
+ vmap = _load_json_map("bmad-voice-map.json")
284
+ amap = _load_json_map("bmad-avatar-map.json")
285
+ roster, skipped = build_roster(resolved, vmap, amap)
286
+ if skipped:
287
+ sys.stderr.write(f"stage-roster: skipped (no saved voice): {', '.join(skipped)}\n")
288
+
289
+ open_cast = bool(resolved.get("open_cast"))
290
+
291
+ if open_cast:
292
+ # Broadcast open-cast rooms rather than skipping them: the room theme is
293
+ # all a receiver needs to render the scene.
294
+ room = {"id": resolved.get("active", ""), "name": resolved.get("name", "")}
295
+ scene = resolved.get("scene")
296
+ if scene:
297
+ room["scene"] = scene
298
+ payload = {"session": session_key, "project": session_key,
299
+ "roster": roster, "openCast": True, "room": room}
300
+ else:
301
+ if not roster:
302
+ sys.stderr.write("stage-roster: no members had a saved voice -- nothing to stage.\n")
303
+ return 0
304
+ payload = {"session": session_key, "project": session_key, "roster": roster}
305
+
306
+ # Test hook: capture the exact payload to a file instead of hitting the
307
+ # network, so the hermetic bats suite can assert the wire shape.
308
+ capture = os.environ.get("AGENTVIBES_STAGE_CAPTURE", "").strip()
309
+ if capture:
310
+ try:
311
+ Path(capture).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
312
+ except OSError as e:
313
+ sys.stderr.write(f"stage-roster: could not write capture file: {e}\n")
314
+ print(f"stage-roster: captured payload for '{session_key}' -> {capture}")
315
+ return 0
316
+
317
+ url = args.url or _default_url()
318
+ ok, msg = post_payload(url, payload)
319
+ kind = "open-cast" if open_cast else f"{len(roster)} member(s)"
320
+ if ok:
321
+ print(f"stage-roster: staged {kind} for '{session_key}' -> {msg.strip()}")
322
+ else:
323
+ print(f"stage-roster: {msg}")
324
+ return 0 # benign: a down receiver must never block the party
325
+
326
+
327
+ if __name__ == "__main__":
328
+ sys.exit(main())