voice-vibecoder 2.26.0__tar.gz → 2.26.2__tar.gz

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 (52) hide show
  1. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/CHANGELOG.md +35 -0
  2. voice_vibecoder-2.26.2/CLAUDE.md +62 -0
  3. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/PKG-INFO +25 -6
  4. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/README.md +24 -5
  5. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/pyproject.toml +1 -1
  6. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/__init__.py +1 -1
  7. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/config.py +3 -0
  8. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/session.py +8 -2
  9. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/agent_task.py +29 -28
  10. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/dispatch.py +85 -28
  11. voice_vibecoder-2.26.2/tests/test_openai_events.py +109 -0
  12. voice_vibecoder-2.26.0/index.html +0 -30
  13. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/.github/workflows/ci.yml +0 -0
  14. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/.github/workflows/release.yml +0 -0
  15. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/.gitignore +0 -0
  16. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/LICENSE +0 -0
  17. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/app.py +0 -0
  18. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/app_config.py +0 -0
  19. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/audio.py +0 -0
  20. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/audio_macos.py +0 -0
  21. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/cli_serve.py +0 -0
  22. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  23. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/code_providers/claude.py +0 -0
  24. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  25. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/code_providers/openclaw.py +0 -0
  26. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/code_providers/registry.py +0 -0
  27. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/instances.py +0 -0
  28. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/server.py +0 -0
  29. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/__init__.py +0 -0
  30. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/definitions.py +0 -0
  31. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/file_tree.py +0 -0
  32. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/tools/handlers.py +0 -0
  33. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/__init__.py +0 -0
  34. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/callbacks.py +0 -0
  35. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/diff_view.py +0 -0
  36. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/help_modal.py +0 -0
  37. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/panels.py +0 -0
  38. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/screen.py +0 -0
  39. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/setup.py +0 -0
  40. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/state.py +0 -0
  41. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/styles.py +0 -0
  42. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/update_modal.py +0 -0
  43. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/ui/wizard.py +0 -0
  44. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  45. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/voice_providers/gemini.py +0 -0
  46. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  47. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/src/voice_vibecoder/worktrees.py +0 -0
  48. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/tests/test_audio_gating.py +0 -0
  49. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/tests/test_file_tree.py +0 -0
  50. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/tests/test_merge_branch.py +0 -0
  51. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/tests/test_session_interrupts.py +0 -0
  52. {voice_vibecoder-2.26.0 → voice_vibecoder-2.26.2}/tests/test_startup_context.py +0 -0
@@ -2,6 +2,41 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.26.2 (2026-07-22)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Never run a disabled agent for a persisted instance
10
+ ([#8](https://github.com/snokam/voice-vibecoder/pull/8),
11
+ [`99b67aa`](https://github.com/snokam/voice-vibecoder/commit/99b67aa74cf4932535d7a16cf5076f6086bb4431))
12
+
13
+ - Per-connection dispatch state via ContextVars
14
+ ([#8](https://github.com/snokam/voice-vibecoder/pull/8),
15
+ [`99b67aa`](https://github.com/snokam/voice-vibecoder/commit/99b67aa74cf4932535d7a16cf5076f6086bb4431))
16
+
17
+
18
+ ## v2.26.1 (2026-07-22)
19
+
20
+ ### Bug Fixes
21
+
22
+ - Per-connection dispatch state via ContextVars
23
+ ([#7](https://github.com/snokam/voice-vibecoder/pull/7),
24
+ [`1d28e3b`](https://github.com/snokam/voice-vibecoder/commit/1d28e3b045a8d0f7623fb0b0d6faa46aedd09357))
25
+
26
+ ### Chores
27
+
28
+ - Remove leftover hello-world test artifact
29
+ ([`1f6f47c`](https://github.com/snokam/voice-vibecoder/commit/1f6f47c98ac203203e8acdce3047a9510072f3fb))
30
+
31
+ ### Documentation
32
+
33
+ - Add CLAUDE.md so agents on this repo have context
34
+ ([`c7267cc`](https://github.com/snokam/voice-vibecoder/commit/c7267ccf76fabc2579f53264047ef9de504e8897))
35
+
36
+ - Sell the real product and delegate non-merge git to agents
37
+ ([`a83ceb5`](https://github.com/snokam/voice-vibecoder/commit/a83ceb536bdd55e7c2199cd9af4f9571f70ffd07))
38
+
39
+
5
40
  ## v2.26.0 (2026-07-13)
6
41
 
7
42
  ### Features
@@ -0,0 +1,62 @@
1
+ # voice-vibecoder (Olaf The Vibecoder)
2
+
3
+ Voice-controlled coding assistant: OpenAI/Azure Realtime API for conversation,
4
+ Claude Code agents in git worktrees for the actual coding. The user talks;
5
+ Olaf converses and delegates invisibly.
6
+
7
+ ## Product principles
8
+
9
+ - The conversation must feel human: short answers, no self-interruption,
10
+ barge-in works, follow the user's language, resume where you left off.
11
+ - The voice bot stays thin: conversation + delegation. Only `merge_branch`
12
+ touches git natively (speed + protecting main); all other git operations
13
+ are delegated to the coding agent in plain words.
14
+ - Agents work invisibly. Olaf never says "I'll send this to an agent."
15
+
16
+ ## Architecture map
17
+
18
+ - `session.py` — conversation-floor state machine. A response is active from
19
+ ResponseCreated to ResponseDone; background injections (agent results,
20
+ narration) queue until the floor is free. Cancel on SpeechStarted only when
21
+ a response is in flight.
22
+ - `audio.py` — sounddevice playback + fallback mic gating (echo gate).
23
+ - `audio_macos.py` — echo-cancelled capture via AVAudioEngine Voice
24
+ Processing I/O (system AEC). Mic stays open; server VAD handles barge-in.
25
+ PyObjC gotcha: `varlist.as_buffer(n)` takes an element count, not bytes.
26
+ - `voice_providers/` — provider protocol; `openai.py` speaks both GA
27
+ (`response.output_audio.*`, nested session schema) and preview wire formats.
28
+ Azure GA: transcription must be `whisper-1` (`gpt-4o-mini-transcribe`
29
+ fails server-side); pass `language` for accuracy.
30
+ - `tools/` — the model's tool surface (definitions.py + dispatch.py +
31
+ handlers.py). New tools need all three plus `_SLOW_TOOLS` in session.py if
32
+ they do subprocess I/O.
33
+ - `worktrees.py` — worktree lifecycle; always prune stale registrations
34
+ (deleted directories stay registered in git as "prunable").
35
+ - `ui/` — Textual TUI. `screen.py` orchestrates; panels/callbacks are mixins.
36
+ - `server.py` — websocket bridge for remote clients (sync_state protocol).
37
+
38
+ ## Testing
39
+
40
+ - `uv run --with pytest pytest tests/` — must pass before any push.
41
+ - Live-endpoint testing: synthesize speech with macOS `say` + `afconvert`
42
+ to 24kHz PCM16, stream over the websocket using `RealtimeSettings.load()`
43
+ credentials. Verify conversation behavior against the real API, not just
44
+ unit tests.
45
+
46
+ ## Releases
47
+
48
+ - Conventional commits pushed to main ARE the release mechanism:
49
+ python-semantic-release cuts the version, updates CHANGELOG, publishes to
50
+ PyPI on every push (feat → minor, fix/perf → patch).
51
+ - Never hand-edit VERSION or CHANGELOG.
52
+ - Downstream consumer: `snokam/monorepo` `olaf/bridge` pins this package —
53
+ bump it after notable releases.
54
+
55
+ ## Gotchas
56
+
57
+ - `app.py` is a PEP 723 script with `editable = true` — required, or uv
58
+ serves stale cached builds of the package.
59
+ - `uv tool` installs live in a separate environment from the checkout; the
60
+ in-app updater detects install kind via `direct_url.json`.
61
+ - CI runs import/syntax/build only (no pytest) across Python 3.10–3.13; keep
62
+ code 3.10-compatible, and mark macOS-only deps with `sys_platform == 'darwin'`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.26.0
3
+ Version: 2.26.2
4
4
  Summary: Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code
5
5
  Project-URL: Homepage, https://github.com/snokam/voice-vibecoder
6
6
  Project-URL: Repository, https://github.com/snokam/voice-vibecoder
@@ -33,7 +33,18 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  # Olaf The Vibecoder
35
35
 
36
- A voice-controlled coding assistant that combines OpenAI's Realtime API for voice conversation with Claude Code for autonomous coding tasks.
36
+ Talk to your codebase like a colleague. You speak an idea; Olaf holds a natural
37
+ voice conversation while coding agents (Claude Code and friends) quietly build
38
+ it on separate git branches. Ask to see the diff, say "merge it", and it lands
39
+ on main — the whole loop, hands-free.
40
+
41
+ ```
42
+ you: "lag en fil med et dikt i repoet"
43
+ olaf: "Klart, jeg setter i gang!" → agent builds on branch 'dikt'
44
+ you: "vis diff" → colorized diff in the TUI
45
+ you: "merge det inn i main"
46
+ olaf: "Ferdig! Dikt er merget inn i main."
47
+ ```
37
48
 
38
49
  ## Quick Start
39
50
 
@@ -47,12 +58,20 @@ On first launch, a setup wizard will guide you through connecting your OpenAI or
47
58
 
48
59
  ## Features
49
60
 
50
- - Voice conversation via OpenAI Realtime API (supports OpenAI and Azure)
51
- - Multi-instance Claude Code agents running in separate git worktrees
52
- - Git diff visualization with file tree and colorized hunks
61
+ - **Natural conversation** full-duplex voice: interrupt Olaf mid-sentence and
62
+ it stops and listens (system echo cancellation on macOS, adaptive echo gate
63
+ elsewhere). Short spoken answers, follows your language, resumes previous
64
+ conversations where they left off
65
+ - **Invisible agent orchestration** — multi-instance Claude Code agents in
66
+ separate git worktrees; Olaf narrates progress and delivers results
67
+ - **Voice-driven git** — show diffs, merge branches into main, clean up
68
+ worktrees, all by voice; conflicts are reported, never half-merged
69
+ - Live file tree per agent (including files the agent just created) and
70
+ GitHub-style diff view
53
71
  - Session persistence across restarts
54
72
  - Push-to-talk and voice activity detection modes
55
- - Multi-language support (English, Norwegian, Swedish)
73
+ - OpenAI and Azure OpenAI realtime (GA voices marin/cedar), Gemini Live
74
+ - Multi-language (English, Norwegian, Swedish) — spoken and transcribed
56
75
 
57
76
  ## Requirements
58
77
 
@@ -1,6 +1,17 @@
1
1
  # Olaf The Vibecoder
2
2
 
3
- A voice-controlled coding assistant that combines OpenAI's Realtime API for voice conversation with Claude Code for autonomous coding tasks.
3
+ Talk to your codebase like a colleague. You speak an idea; Olaf holds a natural
4
+ voice conversation while coding agents (Claude Code and friends) quietly build
5
+ it on separate git branches. Ask to see the diff, say "merge it", and it lands
6
+ on main — the whole loop, hands-free.
7
+
8
+ ```
9
+ you: "lag en fil med et dikt i repoet"
10
+ olaf: "Klart, jeg setter i gang!" → agent builds on branch 'dikt'
11
+ you: "vis diff" → colorized diff in the TUI
12
+ you: "merge det inn i main"
13
+ olaf: "Ferdig! Dikt er merget inn i main."
14
+ ```
4
15
 
5
16
  ## Quick Start
6
17
 
@@ -14,12 +25,20 @@ On first launch, a setup wizard will guide you through connecting your OpenAI or
14
25
 
15
26
  ## Features
16
27
 
17
- - Voice conversation via OpenAI Realtime API (supports OpenAI and Azure)
18
- - Multi-instance Claude Code agents running in separate git worktrees
19
- - Git diff visualization with file tree and colorized hunks
28
+ - **Natural conversation** full-duplex voice: interrupt Olaf mid-sentence and
29
+ it stops and listens (system echo cancellation on macOS, adaptive echo gate
30
+ elsewhere). Short spoken answers, follows your language, resumes previous
31
+ conversations where they left off
32
+ - **Invisible agent orchestration** — multi-instance Claude Code agents in
33
+ separate git worktrees; Olaf narrates progress and delivers results
34
+ - **Voice-driven git** — show diffs, merge branches into main, clean up
35
+ worktrees, all by voice; conflicts are reported, never half-merged
36
+ - Live file tree per agent (including files the agent just created) and
37
+ GitHub-style diff view
20
38
  - Session persistence across restarts
21
39
  - Push-to-talk and voice activity detection modes
22
- - Multi-language support (English, Norwegian, Swedish)
40
+ - OpenAI and Azure OpenAI realtime (GA voices marin/cedar), Gemini Live
41
+ - Multi-language (English, Norwegian, Swedish) — spoken and transcribed
23
42
 
24
43
  ## Requirements
25
44
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.26.0"
7
+ version = "2.26.2"
8
8
  description = "Olaf The Vibecoder — a voice-controlled coding assistant using OpenAI Realtime API + Claude Code"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.26.0"
6
+ __version__ = "2.26.2"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -80,6 +80,7 @@ Avbryt/reset:
80
80
  Merge:
81
81
  - "merge det", "ta det inn i main" → merge_branch. Ved konflikter: foreslå å be \
82
82
  agenten merge main inn i sin branch først.
83
+ - Andre git-operasjoner (push, rebase, revert) → send_to_agent med brukerens ord.
83
84
 
84
85
  Visning:
85
86
  - "vis diff" → show_diff
@@ -139,6 +140,7 @@ Cancel/reset:
139
140
  Merge:
140
141
  - "merge it", "bring it into main" → merge_branch. On conflicts: suggest asking \
141
142
  the agent to merge main into its branch first.
143
+ - Other git operations (push, rebase, revert) → send_to_agent with the user's words.
142
144
 
143
145
  Views:
144
146
  - "show diff" → show_diff
@@ -198,6 +200,7 @@ Avbryt/reset:
198
200
  Merge:
199
201
  - "merga det", "ta in det i main" → merge_branch. Vid konflikter: föreslå att \
200
202
  agenten mergar main in i sin branch först.
203
+ - Övriga git-operationer (push, rebase, revert) → send_to_agent med användarens ord.
201
204
 
202
205
  Visning:
203
206
  - "visa diff" → show_diff
@@ -12,6 +12,7 @@ via the voice provider so ChatGPT can summarize it to the user.
12
12
  """
13
13
 
14
14
  import asyncio
15
+ import contextvars
15
16
  import logging
16
17
  import time
17
18
  from pathlib import Path
@@ -622,9 +623,14 @@ class RealtimeSession:
622
623
  # External tools are always async — pass user JWT for OBO flows
623
624
  result = await handle_tool_call_async(name, arguments, self._user_jwt)
624
625
  elif name in self._SLOW_TOOLS:
625
- # Run slow I/O tools in executor to avoid blocking
626
+ # Run slow I/O tools in executor to avoid blocking. Copy the
627
+ # context so the worker thread sees this connection's dispatch
628
+ # state (ContextVars don't cross into executor threads).
626
629
  loop = asyncio.get_event_loop()
627
- result = await loop.run_in_executor(None, handle_tool_call, name, arguments)
630
+ ctx = contextvars.copy_context()
631
+ result = await loop.run_in_executor(
632
+ None, lambda: ctx.run(handle_tool_call, name, arguments)
633
+ )
628
634
  else:
629
635
  # Sync tools can run directly
630
636
  result = handle_tool_call(name, arguments)
@@ -5,6 +5,7 @@ background thread, and relaying interactive questions back through voice.
5
5
  """
6
6
 
7
7
  import asyncio
8
+ import contextvars
8
9
  import json
9
10
  import logging
10
11
  import threading
@@ -19,6 +20,17 @@ logger = logging.getLogger(__name__)
19
20
 
20
21
  ANSWER_TIMEOUT = 300 # 5 min to answer a question
21
22
 
23
+
24
+ def _start_background(target, *args) -> None:
25
+ """Run ``target`` in a daemon thread that inherits the current context.
26
+
27
+ Dispatch state (registry, repo root, enabled agents, ...) lives in
28
+ ContextVars, which do not propagate to bare threads — so copy the context
29
+ and run inside it, else the thread reads process defaults.
30
+ """
31
+ ctx = contextvars.copy_context()
32
+ threading.Thread(target=lambda: ctx.run(target, *args), daemon=True).start()
33
+
22
34
  _CLAUDE_JSON = Path.home() / ".claude.json"
23
35
 
24
36
  # Cached MCP servers — loaded once per session, files rarely change
@@ -105,7 +117,7 @@ def _create_runner(agent_type: str, permission_mode: str, mcp_servers: dict | No
105
117
 
106
118
  def _handle_send_to_agent(message: str, branch: str | None = None, agent: str | None = None) -> str:
107
119
  from voice_vibecoder.tools.dispatch import (
108
- _default_agent_type, _fuzzy_match_branch, _helper_override,
120
+ _default_agent_type, _enabled_agents, _fuzzy_match_branch, _helper_override,
109
121
  _registry, _repo_root, _resolve_instance,
110
122
  )
111
123
 
@@ -134,8 +146,13 @@ def _handle_send_to_agent(message: str, branch: str | None = None, agent: str |
134
146
  return f"No instance for branch '{branch}'."
135
147
  return "No agent instance available."
136
148
 
137
- # Update agent type if switching on an existing instance
149
+ # Update agent type if switching on an existing instance. A persisted
150
+ # instance may carry an agent that is no longer enabled (e.g. "claude"
151
+ # after the default moved to openclaw); never run a disabled agent —
152
+ # fall back to the default instead of honouring the stale type.
138
153
  resolved_agent = agent or instance.agent_type or target_agent
154
+ if resolved_agent not in _enabled_agents:
155
+ resolved_agent = target_agent
139
156
  if resolved_agent != instance.agent_type:
140
157
  instance.agent_type = resolved_agent
141
158
  instance.session_id = "" # Clear session when switching agents
@@ -168,20 +185,10 @@ def _handle_send_to_agent(message: str, branch: str | None = None, agent: str |
168
185
  is_helper = _repo_root and str(instance.worktree_path) == str(_repo_root)
169
186
 
170
187
  if is_helper and _helper_override:
171
- thread = threading.Thread(
172
- target=_run_helper_override_background,
173
- args=(instance, message, registry),
174
- daemon=True,
175
- )
176
- thread.start()
188
+ _start_background(_run_helper_override_background, instance, message, registry)
177
189
  return "Sent to helper."
178
190
 
179
- thread = threading.Thread(
180
- target=_run_agent_background,
181
- args=(instance, message, registry),
182
- daemon=True,
183
- )
184
- thread.start()
191
+ _start_background(_run_agent_background, instance, message, registry)
185
192
 
186
193
  agent_label = get_agent(instance.agent_type).label
187
194
  return f"Sent to {agent_label}."
@@ -189,7 +196,7 @@ def _handle_send_to_agent(message: str, branch: str | None = None, agent: str |
189
196
 
190
197
  def _handle_send_to_session(message: str, session_name: str | None = None, agent: str | None = None, display_name: str | None = None) -> str:
191
198
  from voice_vibecoder.tools.dispatch import (
192
- _default_agent_type, _helper_override,
199
+ _default_agent_type, _enabled_agents, _helper_override,
193
200
  _registry, _repo_root,
194
201
  )
195
202
 
@@ -221,8 +228,12 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
221
228
  instance.display_name = display_name
222
229
  _registry._save_state()
223
230
 
224
- # Update agent type if switching
231
+ # Update agent type if switching. Never run a disabled agent — a persisted
232
+ # session may carry a stale type (e.g. "claude" after the default moved to
233
+ # openclaw); fall back to the default in that case.
225
234
  resolved_agent = agent or instance.agent_type or target_agent
235
+ if resolved_agent not in _enabled_agents:
236
+ resolved_agent = target_agent
226
237
  if resolved_agent != instance.agent_type:
227
238
  instance.agent_type = resolved_agent
228
239
  instance.session_id = ""
@@ -249,12 +260,7 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
249
260
 
250
261
  # Route through helper override if registered (backward compat)
251
262
  if _helper_override:
252
- thread = threading.Thread(
253
- target=_run_helper_override_background,
254
- args=(instance, message, registry),
255
- daemon=True,
256
- )
257
- thread.start()
263
+ _start_background(_run_helper_override_background, instance, message, registry)
258
264
  result = f"Sent to session '{instance.display_name}'."
259
265
  if is_new_session:
260
266
  all_sessions = registry.get_all_sessions()
@@ -263,12 +269,7 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
263
269
  result += f" (Active sessions: {', '.join(names)})"
264
270
  return result
265
271
 
266
- thread = threading.Thread(
267
- target=_run_agent_background,
268
- args=(instance, message, registry),
269
- daemon=True,
270
- )
271
- thread.start()
272
+ _start_background(_run_agent_background, instance, message, registry)
272
273
 
273
274
  result = f"Sent to session '{instance.display_name}'."
274
275
  if is_new_session:
@@ -11,6 +11,7 @@ tools are automatically included in the tool definitions sent to OpenAI and
11
11
  dispatched through ``handle_tool_call_async``.
12
12
  """
13
13
 
14
+ import contextvars
14
15
  import logging
15
16
  from pathlib import Path
16
17
  from typing import Any, Callable, Awaitable
@@ -21,12 +22,52 @@ from voice_vibecoder.instances import AgentInstance, InstanceRegistry, InstanceT
21
22
 
22
23
  logger = logging.getLogger(__name__)
23
24
 
24
- _registry: InstanceRegistry | None = None
25
- _repo_root: Path | None = None
26
- _agent_timeout: int = 300
27
- _permission_mode: PermissionMode = "bypass"
28
- _enabled_agents: list[str] = ["claude"]
29
- _voice_config: VoiceConfig | None = None
25
+ # Per-connection dispatch state. Held in ContextVars so concurrent realtime
26
+ # sessions in one process each configure and read their own registry/config
27
+ # instead of clobbering shared module globals. The names below are still
28
+ # exposed as module attributes via ``__getattr__`` so callers may keep doing
29
+ # ``from voice_vibecoder.tools.dispatch import _registry`` inside a function.
30
+ _registry_var: contextvars.ContextVar[InstanceRegistry | None] = contextvars.ContextVar(
31
+ "vv_registry", default=None
32
+ )
33
+ _repo_root_var: contextvars.ContextVar[Path | None] = contextvars.ContextVar(
34
+ "vv_repo_root", default=None
35
+ )
36
+ _agent_timeout_var: contextvars.ContextVar[int] = contextvars.ContextVar(
37
+ "vv_agent_timeout", default=300
38
+ )
39
+ _permission_mode_var: contextvars.ContextVar[PermissionMode] = contextvars.ContextVar(
40
+ "vv_permission_mode", default="bypass"
41
+ )
42
+ _enabled_agents_var: contextvars.ContextVar[list[str] | None] = contextvars.ContextVar(
43
+ "vv_enabled_agents", default=None
44
+ )
45
+ _voice_config_var: contextvars.ContextVar[VoiceConfig | None] = contextvars.ContextVar(
46
+ "vv_voice_config", default=None
47
+ )
48
+
49
+ _SCOPED_VARS: dict[str, contextvars.ContextVar] = {
50
+ "_registry": _registry_var,
51
+ "_repo_root": _repo_root_var,
52
+ "_agent_timeout": _agent_timeout_var,
53
+ "_permission_mode": _permission_mode_var,
54
+ "_enabled_agents": _enabled_agents_var,
55
+ "_voice_config": _voice_config_var,
56
+ }
57
+
58
+
59
+ def __getattr__(name: str) -> Any:
60
+ var = _SCOPED_VARS.get(name)
61
+ if var is None:
62
+ raise AttributeError(name)
63
+ value = var.get()
64
+ if name == "_enabled_agents" and not value:
65
+ return ["claude"]
66
+ return value
67
+
68
+
69
+ def _enabled_agents_or_default() -> list[str]:
70
+ return _enabled_agents_var.get() or ["claude"]
30
71
 
31
72
  # ── External tool registry ───────────────────────────────────────────────────
32
73
  # Maps tool name → async handler(name, arguments, user_jwt) -> str
@@ -87,21 +128,33 @@ def configure(
87
128
  user_jwt: str | None = None,
88
129
  voice_config: VoiceConfig | None = None,
89
130
  ) -> None:
90
- global _registry, _repo_root, _agent_timeout, _permission_mode, _enabled_agents, _voice_config
91
- _registry = registry
92
- _repo_root = repo_root
93
- _agent_timeout = agent_timeout
94
- _permission_mode = permission_mode
95
- _enabled_agents = enabled_agents or ["claude"]
96
- _voice_config = voice_config
131
+ _registry_var.set(registry)
132
+ _repo_root_var.set(repo_root)
133
+ _agent_timeout_var.set(agent_timeout)
134
+ _permission_mode_var.set(permission_mode)
135
+ _enabled_agents_var.set(enabled_agents or ["claude"])
136
+ _voice_config_var.set(voice_config)
137
+
138
+
139
+ def set_repo_root(repo_root: Path) -> None:
140
+ """Point the current context's dispatch at another repo (per-connection)."""
141
+ _repo_root_var.set(repo_root)
142
+ registry = _registry_var.get()
143
+ if registry is not None:
144
+ registry.repo_root = str(repo_root)
145
+
146
+
147
+ def current_repo_root() -> Path | None:
148
+ return _repo_root_var.get()
97
149
 
98
150
 
99
151
  def _default_agent_type() -> str:
100
152
  """Return the default agent type from config, or first enabled agent."""
101
- if _voice_config and _voice_config.default_agent:
102
- logger.info(f"Using default_agent from config: {_voice_config.default_agent}")
103
- return _voice_config.default_agent
104
- result = _enabled_agents[0] if _enabled_agents else "claude"
153
+ voice_config = _voice_config_var.get()
154
+ if voice_config and voice_config.default_agent:
155
+ logger.info(f"Using default_agent from config: {voice_config.default_agent}")
156
+ return voice_config.default_agent
157
+ result = _enabled_agents_or_default()[0]
105
158
  logger.info(f"Using fallback agent: {result}")
106
159
  return result
107
160
 
@@ -120,28 +173,30 @@ def _fuzzy_match_branch(query: str, branches: list[str]) -> str | None:
120
173
 
121
174
 
122
175
  def _resolve_instance(branch: str | None = None, session_name: str | None = None) -> AgentInstance | None:
123
- if not _registry:
176
+ registry = _registry_var.get()
177
+ if not registry:
124
178
  return None
125
179
  if session_name:
126
180
  return _resolve_session(session_name)
127
181
  if branch:
128
- inst = _registry.get_by_branch(branch)
182
+ inst = registry.get_by_branch(branch)
129
183
  if inst:
130
184
  return inst
131
185
  # Fuzzy match against registered branches
132
- all_branches = [i.branch for i in _registry.get_all()]
186
+ all_branches = [i.branch for i in registry.get_all()]
133
187
  matched = _fuzzy_match_branch(branch, all_branches)
134
188
  if matched:
135
- return _registry.get_by_branch(matched)
189
+ return registry.get_by_branch(matched)
136
190
  return None
137
- return _registry.get_default()
191
+ return registry.get_default()
138
192
 
139
193
 
140
194
  def _resolve_session(session_name: str) -> AgentInstance | None:
141
195
  """Resolve a session instance by display_name (fuzzy match)."""
142
- if not _registry:
196
+ registry = _registry_var.get()
197
+ if not registry:
143
198
  return None
144
- return _registry.get_session_by_name(session_name)
199
+ return registry.get_session_by_name(session_name)
145
200
 
146
201
 
147
202
  def cancel_instance_task(instance: AgentInstance) -> bool:
@@ -154,19 +209,21 @@ def cancel_instance_task(instance: AgentInstance) -> bool:
154
209
 
155
210
 
156
211
  def cancel_all_tasks() -> int:
157
- if not _registry:
212
+ registry = _registry_var.get()
213
+ if not registry:
158
214
  return 0
159
215
  count = 0
160
- for inst in _registry.get_all():
216
+ for inst in registry.get_all():
161
217
  if cancel_instance_task(inst):
162
218
  count += 1
163
219
  return count
164
220
 
165
221
 
166
222
  def cancel_active_task() -> bool:
167
- if not _registry:
223
+ registry = _registry_var.get()
224
+ if not registry:
168
225
  return False
169
- inst = _registry.get_default()
226
+ inst = registry.get_default()
170
227
  return cancel_instance_task(inst) if inst else False
171
228
 
172
229
 
@@ -0,0 +1,109 @@
1
+ """Event mapping for the OpenAI/Azure provider — GA and preview wire formats."""
2
+
3
+ import pytest
4
+
5
+ from voice_vibecoder.voice_providers import (
6
+ AudioCommitted,
7
+ AudioDelta,
8
+ FunctionCall,
9
+ ResponseCreated,
10
+ ResponseDone,
11
+ SpeechStarted,
12
+ SpeechStopped,
13
+ TranscriptDone,
14
+ UserTranscript,
15
+ VoiceError,
16
+ )
17
+ from voice_vibecoder.voice_providers.openai import OpenAIVoiceProvider
18
+
19
+
20
+ @pytest.fixture
21
+ def provider():
22
+ return OpenAIVoiceProvider(ws_url="wss://x", ws_headers={})
23
+
24
+
25
+ @pytest.mark.parametrize("msg_type", ["response.output_audio.delta", "response.audio.delta"])
26
+ def test_audio_delta_ga_and_preview(provider, msg_type):
27
+ ev = provider._map_event({"type": msg_type, "delta": "QUJD"})
28
+ assert isinstance(ev, AudioDelta)
29
+ assert ev.delta == "QUJD"
30
+
31
+
32
+ @pytest.mark.parametrize("msg_type", [
33
+ "response.output_audio_transcript.done", "response.audio_transcript.done",
34
+ ])
35
+ def test_transcript_done_ga_and_preview(provider, msg_type):
36
+ ev = provider._map_event({"type": msg_type, "transcript": "hei"})
37
+ assert isinstance(ev, TranscriptDone)
38
+ assert ev.transcript == "hei"
39
+
40
+
41
+ def test_lifecycle_events(provider):
42
+ assert isinstance(provider._map_event({"type": "input_audio_buffer.speech_started"}), SpeechStarted)
43
+ assert isinstance(provider._map_event({"type": "input_audio_buffer.speech_stopped"}), SpeechStopped)
44
+ assert isinstance(provider._map_event({"type": "input_audio_buffer.committed"}), AudioCommitted)
45
+ assert isinstance(provider._map_event({"type": "response.created"}), ResponseCreated)
46
+ assert isinstance(provider._map_event({"type": "response.done"}), ResponseDone)
47
+
48
+
49
+ def test_user_transcript_stripped(provider):
50
+ ev = provider._map_event({
51
+ "type": "conversation.item.input_audio_transcription.completed",
52
+ "transcript": " hei du \n",
53
+ })
54
+ assert isinstance(ev, UserTranscript)
55
+ assert ev.transcript == "hei du"
56
+
57
+
58
+ def test_function_call_parses_arguments(provider):
59
+ ev = provider._map_event({
60
+ "type": "response.function_call_arguments.done",
61
+ "call_id": "c1",
62
+ "name": "merge_branch",
63
+ "arguments": '{"branch": "dikt"}',
64
+ })
65
+ assert isinstance(ev, FunctionCall)
66
+ assert ev.name == "merge_branch"
67
+ assert ev.arguments == {"branch": "dikt"}
68
+
69
+
70
+ def test_function_call_invalid_json_gives_empty_args(provider):
71
+ ev = provider._map_event({
72
+ "type": "response.function_call_arguments.done",
73
+ "call_id": "c1",
74
+ "name": "x",
75
+ "arguments": "{broken",
76
+ })
77
+ assert ev.arguments == {}
78
+
79
+
80
+ def test_cancel_errors_are_ignorable(provider):
81
+ ev = provider._map_event({
82
+ "type": "error",
83
+ "error": {"message": "Cannot cancel: no active response"},
84
+ })
85
+ assert isinstance(ev, VoiceError)
86
+ assert ev.is_ignorable
87
+
88
+
89
+ def test_real_errors_are_not_ignorable(provider):
90
+ ev = provider._map_event({
91
+ "type": "error",
92
+ "error": {"message": "Invalid session configuration"},
93
+ })
94
+ assert isinstance(ev, VoiceError)
95
+ assert not ev.is_ignorable
96
+
97
+
98
+ def test_unknown_events_are_dropped(provider):
99
+ assert provider._map_event({"type": "rate_limits.updated"}) is None
100
+
101
+
102
+ def test_ga_session_body_includes_transcription_language():
103
+ p = OpenAIVoiceProvider(
104
+ ws_url="wss://x", ws_headers={},
105
+ transcription_model="whisper-1", transcription_language="no", ga=True,
106
+ )
107
+ body = p._session_body("inst", "marin", [], None)
108
+ assert body["audio"]["input"]["transcription"] == {"model": "whisper-1", "language": "no"}
109
+ assert body["audio"]["output"]["voice"] == "marin"
@@ -1,30 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Hello World</title>
7
- <style>
8
- body {
9
- margin: 0;
10
- display: flex;
11
- justify-content: center;
12
- align-items: center;
13
- min-height: 100vh;
14
- background-color: #f0f4f8;
15
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
16
- }
17
-
18
- h1 {
19
- font-size: 3rem;
20
- color: #2d3748;
21
- background: linear-gradient(135deg, #667eea, #764ba2);
22
- -webkit-background-clip: text;
23
- -webkit-text-fill-color: transparent;
24
- }
25
- </style>
26
- </head>
27
- <body>
28
- <h1>Hello, World!</h1>
29
- </body>
30
- </html>