voice-vibecoder 2.2.0__tar.gz → 2.4.0__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 (42) hide show
  1. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/CHANGELOG.md +11 -0
  2. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/PKG-INFO +1 -2
  3. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/pyproject.toml +1 -2
  4. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/app_config.py +7 -0
  6. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/cli_serve.py +0 -2
  7. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/claude.py +3 -0
  8. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/server.py +19 -94
  9. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/agent_task.py +5 -1
  10. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/definitions.py +9 -3
  11. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/dispatch.py +1 -0
  12. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/.github/workflows/ci.yml +0 -0
  13. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/.github/workflows/release.yml +0 -0
  14. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/.gitignore +0 -0
  15. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/LICENSE +0 -0
  16. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/README.md +0 -0
  17. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/app.py +0 -0
  18. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/index.html +0 -0
  19. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/audio.py +0 -0
  20. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  21. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  22. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  23. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/config.py +0 -0
  24. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/instances.py +0 -0
  25. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/session.py +0 -0
  26. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  27. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/file_tree.py +0 -0
  28. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/tools/handlers.py +0 -0
  29. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  30. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  31. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  32. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  33. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/panels.py +0 -0
  34. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/screen.py +0 -0
  35. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/setup.py +0 -0
  36. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/state.py +0 -0
  37. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/styles.py +0 -0
  38. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  39. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  40. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  41. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  42. {voice_vibecoder-2.2.0 → voice_vibecoder-2.4.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.4.0 (2026-02-19)
6
+
7
+ ### Bug Fixes
8
+
9
+ - **server**: Handle unexpected errors during authentication
10
+ ([`f0ac102`](https://github.com/snokam/voice-vibecoder/commit/f0ac1025cac939f8fc048c796d63668c1d687f9e))
11
+
12
+
13
+ ## v2.3.0 (2026-02-19)
14
+
15
+
5
16
  ## v2.2.0 (2026-02-19)
6
17
 
7
18
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.2.0
3
+ Version: 2.4.0
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
@@ -23,7 +23,6 @@ Requires-Python: >=3.10
23
23
  Requires-Dist: claude-agent-sdk>=0.1.30
24
24
  Requires-Dist: numpy>=1.24
25
25
  Requires-Dist: platformdirs>=4.0
26
- Requires-Dist: pyjwt[crypto]>=2.8
27
26
  Requires-Dist: rich>=13.0
28
27
  Requires-Dist: sounddevice>=0.4
29
28
  Requires-Dist: textual>=0.50
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.2.0"
7
+ version = "2.4.0"
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"
@@ -31,7 +31,6 @@ dependencies = [
31
31
  "numpy>=1.24",
32
32
  "claude-agent-sdk>=0.1.30",
33
33
  "platformdirs>=4.0",
34
- "PyJWT[crypto]>=2.8",
35
34
  ]
36
35
 
37
36
  [project.urls]
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.2.0"
6
+ __version__ = "2.4.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -9,6 +9,7 @@ Embedders can override any directory to colocate everything (e.g. ~/.snokam/).
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
+ from collections.abc import Awaitable
12
13
  from dataclasses import dataclass, field
13
14
  from pathlib import Path
14
15
  from typing import Callable
@@ -65,3 +66,9 @@ class VoiceConfig:
65
66
  # Called before an agent task runs. Receives (mcp_servers: dict, user_jwt: str | None)
66
67
  # and can mutate mcp_servers in place (e.g. inject OBO tokens).
67
68
  pre_agent_hook: Callable[[dict, str | None], None] | None = None
69
+
70
+ # Async callback for WebSocket authentication.
71
+ # Receives the raw token string, returns (user_email, raw_token).
72
+ # Raises ValueError on validation failure.
73
+ # When None, auth is disabled (no auth frame expected from clients).
74
+ authenticate: Callable[[str], Awaitable[tuple[str, str]]] | None = None
@@ -61,7 +61,6 @@ def main(voice_config: VoiceConfig | None = None) -> None:
61
61
  parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
62
62
  parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)")
63
63
  parser.add_argument("--repo", type=str, default=None, help="Repo root (auto-detects git root)")
64
- parser.add_argument("--no-auth", action="store_true", help="Disable JWT authentication (for local dev)")
65
64
  parser.add_argument("-v", "--verbose", action="store_true", help="Debug logging")
66
65
  args = parser.parse_args()
67
66
 
@@ -94,7 +93,6 @@ def main(voice_config: VoiceConfig | None = None) -> None:
94
93
  repo_root=repo_root,
95
94
  voice_config=config,
96
95
  settings=settings,
97
- no_auth=args.no_auth,
98
96
  ))
99
97
 
100
98
 
@@ -78,6 +78,9 @@ def describe_tool_call(name: str, inp: dict) -> str:
78
78
  elif name == "WebFetch":
79
79
  url = inp.get("url", "")
80
80
  return f"\\[WebFetch] {url[:60]}"
81
+ elif name == "ToolSearch":
82
+ query = inp.get("query", "")
83
+ return f"\\[Search tools] {query}" if query else "\\[Search tools]"
81
84
  elif name.startswith("mcp__"):
82
85
  # MCP tool: mcp__<server>__<tool> → [Server: tool description]
83
86
  parts = name.split("__", 2)
@@ -29,12 +29,9 @@ import asyncio
29
29
  import json
30
30
  import logging
31
31
  import os
32
- import time
33
32
  from pathlib import Path
34
33
  from typing import Any
35
- from urllib.parse import urlparse
36
34
 
37
- import jwt
38
35
  import websockets
39
36
  from websockets.asyncio.server import ServerConnection
40
37
  from websockets.http11 import Request, Response
@@ -47,70 +44,6 @@ from voice_vibecoder.voice_providers.openai import OpenAIVoiceProvider
47
44
 
48
45
  logger = logging.getLogger(__name__)
49
46
 
50
- # ── JWT validation for Azure AD ──────────────────────────────────────────────
51
-
52
- _JWKS_CACHE_TTL = 3600 # 1 hour
53
-
54
-
55
- class _JWKSEntry:
56
- __slots__ = ("client", "fetched_at")
57
-
58
- def __init__(self, client: jwt.PyJWKClient, fetched_at: float) -> None:
59
- self.client = client
60
- self.fetched_at = fetched_at
61
-
62
-
63
- _jwks_cache: dict[str, _JWKSEntry] = {}
64
-
65
-
66
- def _get_jwks_client(tenant_id: str) -> jwt.PyJWKClient:
67
- """Return a cached JWKS client for the given Azure AD tenant."""
68
- now = time.monotonic()
69
- entry = _jwks_cache.get(tenant_id)
70
- if entry and (now - entry.fetched_at) < _JWKS_CACHE_TTL:
71
- return entry.client
72
-
73
- jwks_url = f"https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys"
74
- client = jwt.PyJWKClient(jwks_url)
75
- _jwks_cache[tenant_id] = _JWKSEntry(client, now)
76
- return client
77
-
78
-
79
- def validate_token(token: str, tenant_id: str, audience: str) -> dict:
80
- """Validate an Azure AD JWT and return decoded claims.
81
-
82
- Raises ValueError if the token is invalid.
83
- """
84
- try:
85
- jwks_client = _get_jwks_client(tenant_id)
86
- signing_key = jwks_client.get_signing_key_from_jwt(token)
87
- # Accept both v1 (aud=client_id) and v2 (aud=api://client_id) tokens,
88
- # and both v1/v2 issuer formats.
89
- audiences = [audience]
90
- if audience.startswith("api://"):
91
- audiences.append(audience[len("api://"):])
92
- issuers = [
93
- f"https://login.microsoftonline.com/{tenant_id}/v2.0",
94
- f"https://sts.windows.net/{tenant_id}/",
95
- ]
96
- claims = jwt.decode(
97
- token,
98
- signing_key.key,
99
- algorithms=["RS256"],
100
- audience=audiences,
101
- issuer=issuers,
102
- )
103
- return claims
104
- except jwt.ExpiredSignatureError:
105
- raise ValueError("Token has expired")
106
- except jwt.InvalidAudienceError:
107
- raise ValueError("Invalid audience")
108
- except jwt.InvalidIssuerError:
109
- raise ValueError("Invalid issuer")
110
- except Exception as exc:
111
- raise ValueError(f"Token validation failed: {exc}")
112
-
113
-
114
47
  # ── Auth timeout ─────────────────────────────────────────────────────────────
115
48
 
116
49
  AUTH_TIMEOUT_SECONDS = 10
@@ -234,6 +167,7 @@ class WebSocketBridge:
234
167
  "status": inst.status.value,
235
168
  "branch": inst.branch,
236
169
  "agentType": inst.agent_type,
170
+ "displayName": inst.display_key,
237
171
  })
238
172
  # Send buffered output from previous session
239
173
  if inst.output_buffer:
@@ -283,12 +217,14 @@ class WebSocketBridge:
283
217
  inst = self._session.registry.get_by_id(instance_id) if self._session else None
284
218
  branch = inst.branch if inst else ""
285
219
  agent_type = inst.agent_type if inst else "claude"
220
+ display_name = inst.display_key if inst else branch
286
221
  self._send({
287
222
  "type": "instance_status",
288
223
  "instanceId": instance_id,
289
224
  "status": status.value,
290
225
  "branch": branch,
291
226
  "agentType": agent_type,
227
+ "displayName": display_name,
292
228
  })
293
229
 
294
230
  def _on_tool_call(self, name: str, arguments: dict) -> None:
@@ -310,11 +246,11 @@ def _log_send_error(future: asyncio.Future) -> None:
310
246
 
311
247
  async def _authenticate_ws(
312
248
  ws: ServerConnection,
313
- tenant_id: str,
314
- audience: str,
249
+ authenticate: Any,
315
250
  ) -> tuple[str, str] | None:
316
251
  """Wait for an auth message as the first frame.
317
252
 
253
+ *authenticate* is the ``VoiceConfig.authenticate`` async callback.
318
254
  Returns ``(user_email, raw_token)`` on success, ``None`` on failure.
319
255
  """
320
256
  try:
@@ -335,15 +271,18 @@ async def _authenticate_ws(
335
271
  token = token[7:]
336
272
 
337
273
  try:
338
- claims = validate_token(token, tenant_id, audience)
274
+ result = await authenticate(token)
339
275
  except ValueError as exc:
340
- logger.warning("JWT validation failed: %s", exc)
276
+ logger.warning("Authentication failed: %s", exc)
277
+ await ws.send(json.dumps({"type": "error", "message": "Authentication failed"}))
278
+ return None
279
+ except Exception as exc:
280
+ logger.error("Unexpected error during authentication: %s", exc, exc_info=True)
341
281
  await ws.send(json.dumps({"type": "error", "message": "Authentication failed"}))
342
282
  return None
343
283
 
344
284
  await ws.send(json.dumps({"type": "auth_ok"}))
345
- user_email = claims.get("email") or claims.get("preferred_username") or claims.get("upn") or "unknown"
346
- return (user_email, token)
285
+ return result
347
286
 
348
287
 
349
288
  async def _handle_connection(
@@ -351,17 +290,15 @@ async def _handle_connection(
351
290
  settings: RealtimeSettings,
352
291
  repo_root: Path,
353
292
  voice_config: VoiceConfig | None,
354
- auth_enabled: bool,
355
- tenant_id: str,
356
- audience: str,
357
293
  ) -> None:
358
294
  remote = ws.remote_address
359
295
  logger.info("Client connected from %s", remote)
360
296
 
361
297
  user_email = None
362
298
  user_jwt = None
363
- if auth_enabled:
364
- auth_result = await _authenticate_ws(ws, tenant_id, audience)
299
+ authenticate = voice_config.authenticate if voice_config else None
300
+ if authenticate:
301
+ auth_result = await _authenticate_ws(ws, authenticate)
365
302
  if not auth_result:
366
303
  logger.warning("Auth failed for %s — closing", remote)
367
304
  await ws.close(4001, "Unauthorized")
@@ -384,7 +321,6 @@ async def serve(
384
321
  repo_root: Path | None = None,
385
322
  voice_config: VoiceConfig | None = None,
386
323
  settings: RealtimeSettings | None = None,
387
- no_auth: bool = False,
388
324
  allowed_origins: list[str] | None = None,
389
325
  ) -> None:
390
326
  """Start the WebSocket server. Blocks until stopped."""
@@ -401,22 +337,11 @@ async def serve(
401
337
  logger.error("Not configured. Run 'vibecoder' TUI first to set API keys.")
402
338
  return
403
339
 
404
- # JWT auth enabled when both env vars are set
405
- tenant_id = os.environ.get("AZURE_AD_TENANT_ID", "")
406
- client_id = os.environ.get("AZURE_AD_CLIENT_ID", "")
407
- auth_enabled = bool(tenant_id and client_id) and not no_auth
340
+ auth_enabled = voice_config is not None and voice_config.authenticate is not None
408
341
  if auth_enabled:
409
- audience = f"api://{client_id}"
410
- logger.info("JWT auth enabled tenant=%s audience=%s", tenant_id, audience)
411
- elif no_auth:
412
- audience = ""
413
- logger.warning("JWT auth explicitly disabled via --no-auth")
342
+ logger.info("WebSocket auth enabled (authenticate callback provided)")
414
343
  else:
415
- audience = ""
416
- logger.warning(
417
- "JWT auth disabled (AZURE_AD_TENANT_ID / AZURE_AD_CLIENT_ID not set). "
418
- "Set these env vars or pass --no-auth to suppress this warning."
419
- )
344
+ logger.info("WebSocket auth disabled (no authenticate callback)")
420
345
 
421
346
  # Origin checking — build allowed set from env var + explicit param
422
347
  origin_env = os.environ.get("VOICE_ALLOWED_ORIGINS", "")
@@ -441,7 +366,7 @@ async def serve(
441
366
  )
442
367
 
443
368
  async def handler(ws: ServerConnection) -> None:
444
- await _handle_connection(ws, settings, repo_root, voice_config, auth_enabled, tenant_id, audience)
369
+ await _handle_connection(ws, settings, repo_root, voice_config)
445
370
 
446
371
  async with websockets.serve(handler, host, port, process_request=process_request):
447
372
  await asyncio.Future() # Block forever
@@ -176,7 +176,7 @@ def _handle_send_to_agent(message: str, branch: str | None = None, agent: str |
176
176
  return f"Sent to {agent_label}."
177
177
 
178
178
 
179
- def _handle_send_to_session(message: str, session_name: str | None = None, agent: str | None = None) -> str:
179
+ def _handle_send_to_session(message: str, session_name: str | None = None, agent: str | None = None, display_name: str | None = None) -> str:
180
180
  from voice_vibecoder.tools.dispatch import (
181
181
  _default_agent_type, _helper_override,
182
182
  _registry, _repo_root,
@@ -200,6 +200,10 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
200
200
  instance = _registry.get_session_by_name(session_name)
201
201
  if not instance:
202
202
  instance = _registry.create_session(session_name, agent_type=target_agent)
203
+ # Update display_name if a better one was provided by the LLM
204
+ if display_name and display_name != instance.display_name:
205
+ instance.display_name = display_name
206
+ _registry._save_state()
203
207
 
204
208
  # Update agent type if switching
205
209
  resolved_agent = agent or instance.agent_type or target_agent
@@ -52,9 +52,11 @@ _CORE_TOOLS: list[dict[str, Any]] = [
52
52
  "Send a NON-CODE task to a session agent. Returns immediately — the agent works in the background. "
53
53
  "Use for anything that doesn't change code: research, Jira lookups, CI status, questions, "
54
54
  "documentation, planning, analysis, etc. "
55
- "Use descriptive session names (e.g. 'jira-research', 'ci-status', 'architecture-review'). "
55
+ "Use short lowercase kebab-case session keys (e.g. 'jira', 'ci-status', 'events'). "
56
56
  "Session names are fuzzy-matched — reuse existing sessions for the same topic. "
57
- "If session_name is omitted, one is auto-generated from the message."
57
+ "If session_name is omitted, one is auto-generated from the message. "
58
+ "Always provide a short, human-friendly display_name in the user's language "
59
+ "(e.g. 'Arrangementer', 'Lønn', 'Jira-oppslag', 'Events', 'Salary')."
58
60
  ),
59
61
  "parameters": {
60
62
  "type": "object",
@@ -65,7 +67,11 @@ _CORE_TOOLS: list[dict[str, Any]] = [
65
67
  },
66
68
  "session_name": {
67
69
  "type": "string",
68
- "description": "Descriptive session name (e.g. 'jira-research'). Auto-generated if omitted.",
70
+ "description": "Short kebab-case key (e.g. 'events', 'salary'). Auto-generated if omitted.",
71
+ },
72
+ "display_name": {
73
+ "type": "string",
74
+ "description": "Human-friendly label shown in the UI (e.g. 'Arrangementer', 'Lønn'). Required.",
69
75
  },
70
76
  "agent": {
71
77
  "type": "string",
@@ -214,6 +214,7 @@ def handle_tool_call(name: str, arguments: dict[str, Any]) -> str:
214
214
  arguments.get("message", ""),
215
215
  arguments.get("session_name"),
216
216
  arguments.get("agent"),
217
+ arguments.get("display_name"),
217
218
  )
218
219
  elif name == "switch_agent":
219
220
  return _handle_switch_agent(
File without changes
File without changes