voice-vibecoder 2.5.2__tar.gz → 2.6.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.5.2 → voice_vibecoder-2.6.0}/CHANGELOG.md +21 -0
  2. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/PKG-INFO +1 -1
  3. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/pyproject.toml +1 -1
  4. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/__init__.py +1 -1
  5. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/config.py +26 -4
  6. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/instances.py +6 -12
  7. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/session.py +8 -2
  8. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/agent_task.py +68 -40
  9. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/file_tree.py +24 -42
  10. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/handlers.py +20 -9
  11. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/.github/workflows/ci.yml +0 -0
  12. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/.github/workflows/release.yml +0 -0
  13. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/.gitignore +0 -0
  14. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/LICENSE +0 -0
  15. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/README.md +0 -0
  16. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/app.py +0 -0
  17. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/index.html +0 -0
  18. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/app_config.py +0 -0
  19. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/audio.py +0 -0
  20. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/cli_serve.py +0 -0
  21. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/code_providers/__init__.py +0 -0
  22. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/code_providers/claude.py +0 -0
  23. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/code_providers/cursor.py +0 -0
  24. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/code_providers/registry.py +0 -0
  25. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/server.py +0 -0
  26. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/__init__.py +0 -0
  27. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/definitions.py +0 -0
  28. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/tools/dispatch.py +0 -0
  29. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/__init__.py +0 -0
  30. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/callbacks.py +0 -0
  31. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/diff_view.py +0 -0
  32. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/help_modal.py +0 -0
  33. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/panels.py +0 -0
  34. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/screen.py +0 -0
  35. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/setup.py +0 -0
  36. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/state.py +0 -0
  37. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/styles.py +0 -0
  38. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/update_modal.py +0 -0
  39. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/ui/wizard.py +0 -0
  40. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/voice_providers/__init__.py +0 -0
  41. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/voice_providers/openai.py +0 -0
  42. {voice_vibecoder-2.5.2 → voice_vibecoder-2.6.0}/src/voice_vibecoder/worktrees.py +0 -0
@@ -2,6 +2,27 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v2.6.0 (2026-02-20)
6
+
7
+ ### Features
8
+
9
+ - **agent_task**: Support matching by display name in session lookup
10
+ ([`a97e0e6`](https://github.com/snokam/voice-vibecoder/commit/a97e0e67fdf7178ddee182acc275957a471e2691))
11
+
12
+ ### Refactoring
13
+
14
+ - **instances**: Simplify branch key assignment logic
15
+ ([`58f5d34`](https://github.com/snokam/voice-vibecoder/commit/58f5d3475d1696dd3b92252e3843ee2e4a04826b))
16
+
17
+
18
+ ## v2.5.3 (2026-02-20)
19
+
20
+ ### Bug Fixes
21
+
22
+ - Prevent duplicate sessions by embedding active instances in instructions
23
+ ([`7f493dc`](https://github.com/snokam/voice-vibecoder/commit/7f493dc275d7913edc760afc8ac3b2c066062954))
24
+
25
+
5
26
  ## v2.5.2 (2026-02-20)
6
27
 
7
28
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: voice-vibecoder
3
- Version: 2.5.2
3
+ Version: 2.6.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "voice-vibecoder"
7
- version = "2.5.2"
7
+ version = "2.6.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"
@@ -3,7 +3,7 @@
3
3
  A voice-controlled coding assistant using OpenAI Realtime API + Claude Code.
4
4
  """
5
5
 
6
- __version__ = "2.5.2"
6
+ __version__ = "2.6.0"
7
7
 
8
8
  from voice_vibecoder.app_config import VoiceConfig
9
9
  from voice_vibecoder.ui.screen import VoiceCodingScreen
@@ -47,9 +47,12 @@ Branch-navn fuzzy-matches — f.eks. "branding" matcher "feat/branding-refresh".
47
47
  send_to_session med beskrivende navn. Gjenbruk eksisterende sesjoner for samme tema.
48
48
 
49
49
  Sesjoner:
50
+ - SJEKK ALLTID listen over aktive sesjoner nedenfor før du oppretter en ny.
51
+ - Hvis en sesjon dekker samme tema, GJENBRUK den ved å bruke det eksakte navnet.
52
+ - Opprett kun en ny sesjon for et genuint annet tema.
50
53
  - Bruk beskrivende navn: "jira-research", "ci-status", "arkitektur-gjennomgang".
51
- - Gjenbruk en sesjon hvis temaet er det samme. Opprett ny for nytt tema.
52
54
  - remove_session for å fjerne en sesjon.
55
+ {active_instances}
53
56
 
54
57
  Agenter:
55
58
  - Tilgjengelige agenter: {agent_names}.
@@ -100,9 +103,12 @@ Branch names are fuzzy-matched — e.g. "branding" matches "feat/branding-refres
100
103
  send_to_session with a descriptive name. Reuse existing sessions for the same topic.
101
104
 
102
105
  Sessions:
106
+ - ALWAYS check the active sessions list below before creating a new one.
107
+ - If a session covers the same topic, REUSE it by using its exact name.
108
+ - Only create a new session for a genuinely different topic.
103
109
  - Use descriptive names: "jira-research", "ci-status", "architecture-review".
104
- - Reuse a session if the topic is the same. Create new for a different topic.
105
110
  - remove_session to remove a session.
111
+ {active_instances}
106
112
 
107
113
  Agents:
108
114
  - Available agents: {agent_names}.
@@ -153,9 +159,12 @@ Branch-namn fuzzy-matchas — t.ex. "branding" matchar "feat/branding-refresh".
153
159
  send_to_session med beskrivande namn. Återanvänd befintliga sessioner för samma ämne.
154
160
 
155
161
  Sessioner:
162
+ - KONTROLLERA ALLTID listan över aktiva sessioner nedan innan du skapar en ny.
163
+ - Om en session täcker samma ämne, ÅTERANVÄND den genom att använda det exakta namnet.
164
+ - Skapa bara en ny session för ett genuint annorlunda ämne.
156
165
  - Använd beskrivande namn: "jira-research", "ci-status", "arkitektur-granskning".
157
- - Återanvänd en session om ämnet är detsamma. Skapa ny för nytt ämne.
158
166
  - remove_session för att ta bort en session.
167
+ {active_instances}
159
168
 
160
169
  Agenter:
161
170
  - Tillgängliga agenter: {agent_names}.
@@ -195,12 +204,25 @@ def get_system_instructions(
195
204
  context: str = "",
196
205
  overrides: dict[str, str] | None = None,
197
206
  enabled_agents: list[str] | None = None,
207
+ active_instances: str = "",
198
208
  ) -> str:
199
209
  if overrides and language in overrides:
200
210
  base = overrides[language]
201
211
  else:
202
212
  base = _SYSTEM_INSTRUCTIONS.get(language, _SYSTEM_INSTRUCTIONS["en"])
203
- base = base.format(agent_names=_agent_names(enabled_agents))
213
+ instances_text = active_instances or "(No active sessions or agents.)"
214
+ has_instances_placeholder = "{active_instances}" in base
215
+ try:
216
+ base = base.format(
217
+ agent_names=_agent_names(enabled_agents),
218
+ active_instances=instances_text,
219
+ )
220
+ except KeyError:
221
+ # Override instructions may not have all placeholders
222
+ base = base.format(agent_names=_agent_names(enabled_agents))
223
+ # If the template didn't have {active_instances}, append inline
224
+ if not has_instances_placeholder and active_instances:
225
+ base += f"\n\n{instances_text}"
204
226
  if context:
205
227
  return f"{base}\n\n{context}"
206
228
  return base
@@ -125,9 +125,6 @@ class InstanceRegistry:
125
125
  self.on_status_change(instance.instance_id, instance.status)
126
126
  if self.on_instance_change:
127
127
  self.on_instance_change()
128
- # Start file tree updates for code instances (skip for sessions)
129
- from voice_vibecoder.tools.file_tree import start_file_tree_updates
130
- start_file_tree_updates(instance.instance_id, worktree_path, self)
131
128
  return instance
132
129
 
133
130
  def create_session(self, name: str, agent_type: str = "claude") -> AgentInstance:
@@ -136,16 +133,20 @@ class InstanceRegistry:
136
133
  if existing:
137
134
  return existing
138
135
  with self._lock:
136
+ # Re-check under lock to prevent race between concurrent creates
137
+ branch_key = f"_session_{name}"
138
+ if branch_key in self._branch_index:
139
+ return self._instances[self._branch_index[branch_key]]
139
140
  instance = AgentInstance(
140
141
  instance_id=uuid.uuid4().hex[:8],
141
- branch=f"_session_{name}",
142
+ branch=branch_key,
142
143
  worktree_path="",
143
144
  agent_type=agent_type,
144
145
  instance_type=InstanceType.SESSION,
145
146
  display_name=name,
146
147
  )
147
148
  self._instances[instance.instance_id] = instance
148
- self._branch_index[instance.branch] = instance.instance_id
149
+ self._branch_index[branch_key] = instance.instance_id
149
150
  self._save_state()
150
151
  if self.on_status_change:
151
152
  self.on_status_change(instance.instance_id, instance.status)
@@ -415,11 +416,4 @@ class InstanceRegistry:
415
416
  self._branch_index[branch] = instance.instance_id
416
417
  loaded.append(instance)
417
418
 
418
- # Start file tree updates for code instances only
419
- from voice_vibecoder.tools.file_tree import start_file_tree_updates
420
- for inst in loaded:
421
- if inst.instance_type == InstanceType.SESSION:
422
- continue
423
- start_file_tree_updates(inst.instance_id, inst.worktree_path, self)
424
-
425
419
  return loaded
@@ -128,13 +128,18 @@ class RealtimeSession:
128
128
  self._on_status("connected")
129
129
 
130
130
  async def configure_session(self) -> None:
131
- from voice_vibecoder.tools.handlers import get_startup_context
131
+ from voice_vibecoder.tools.agent_task import warm_mcp_cache
132
+ from voice_vibecoder.tools.handlers import get_active_instances_text, get_startup_context
133
+
134
+ # Pre-warm MCP server cache so first agent spawn doesn't pay the cost
135
+ warm_mcp_cache()
132
136
 
133
137
  s = self._settings
134
138
  overrides = self._voice_config.system_instructions if self._voice_config else None
135
139
  instructions = get_system_instructions(
136
140
  s.language, context=get_startup_context(),
137
141
  overrides=overrides, enabled_agents=s.enabled_agents,
142
+ active_instances=get_active_instances_text(),
138
143
  )
139
144
 
140
145
  vad: VADSettings | None = None
@@ -304,12 +309,13 @@ class RealtimeSession:
304
309
  asyncio.run_coroutine_threadsafe(self._refresh_instructions(), self._loop)
305
310
 
306
311
  async def _refresh_instructions(self) -> None:
307
- from voice_vibecoder.tools.handlers import get_startup_context
312
+ from voice_vibecoder.tools.handlers import get_active_instances_text, get_startup_context
308
313
 
309
314
  overrides = self._voice_config.system_instructions if self._voice_config else None
310
315
  instructions = get_system_instructions(
311
316
  self._settings.language, context=get_startup_context(),
312
317
  overrides=overrides, enabled_agents=self._settings.enabled_agents,
318
+ active_instances=get_active_instances_text(),
313
319
  )
314
320
  try:
315
321
  await self._provider.update_session(instructions=instructions)
@@ -23,10 +23,11 @@ _CLAUDE_JSON = Path.home() / ".claude.json"
23
23
 
24
24
  # Cached MCP servers — loaded once per session, files rarely change
25
25
  _mcp_cache: dict[str, dict] | None = None
26
+ _mcp_lock = threading.Lock()
26
27
 
27
28
 
28
29
  def _load_mcp_servers() -> dict[str, dict]:
29
- """Load MCP servers (cached after first call).
30
+ """Load MCP servers (cached after first call, thread-safe).
30
31
 
31
32
  Priority order:
32
33
  1. MCP_SERVERS_JSON environment variable (for Azure/production)
@@ -37,50 +38,60 @@ def _load_mcp_servers() -> dict[str, dict]:
37
38
  if _mcp_cache is not None:
38
39
  return _mcp_cache
39
40
 
40
- import os
41
- from voice_vibecoder.tools.dispatch import _repo_root
41
+ with _mcp_lock:
42
+ # Double-check after acquiring the lock
43
+ if _mcp_cache is not None:
44
+ return _mcp_cache
45
+
46
+ import os
47
+ from voice_vibecoder.tools.dispatch import _repo_root
48
+
49
+ servers: dict[str, dict] = {}
42
50
 
43
- servers: dict[str, dict] = {}
51
+ # Load from .mcp.json in repo root
52
+ if _repo_root:
53
+ mcp_json = Path(_repo_root) / ".mcp.json"
54
+ if mcp_json.exists():
55
+ try:
56
+ data = json.loads(mcp_json.read_text())
57
+ servers.update(data.get("mcpServers", {}))
58
+ except Exception as e:
59
+ logger.warning("Failed to read .mcp.json: %s", e)
44
60
 
45
- # Load from .mcp.json in repo root
46
- if _repo_root:
47
- mcp_json = Path(_repo_root) / ".mcp.json"
48
- if mcp_json.exists():
61
+ # Load from ~/.claude.json
62
+ if _repo_root and _CLAUDE_JSON.exists():
49
63
  try:
50
- data = json.loads(mcp_json.read_text())
51
- servers.update(data.get("mcpServers", {}))
64
+ data = json.loads(_CLAUDE_JSON.read_text())
65
+ project_key = str(Path(_repo_root))
66
+ project_data = data.get("projects", {}).get(project_key, {})
67
+ local_servers = project_data.get("mcpServers", {})
68
+ servers.update(local_servers)
52
69
  except Exception as e:
53
- logger.warning("Failed to read .mcp.json: %s", e)
70
+ logger.warning("Failed to read ~/.claude.json: %s", e)
54
71
 
55
- # Load from ~/.claude.json
56
- if _repo_root and _CLAUDE_JSON.exists():
57
- try:
58
- data = json.loads(_CLAUDE_JSON.read_text())
59
- project_key = str(Path(_repo_root))
60
- project_data = data.get("projects", {}).get(project_key, {})
61
- local_servers = project_data.get("mcpServers", {})
62
- servers.update(local_servers)
63
- except Exception as e:
64
- logger.warning("Failed to read ~/.claude.json: %s", e)
65
-
66
- # MCP_SERVERS_JSON takes precedence (for Azure Container Apps)
67
- mcp_env = os.environ.get("MCP_SERVERS_JSON")
68
- if mcp_env:
69
- try:
70
- env_servers = json.loads(mcp_env)
71
- servers.update(env_servers.get("mcpServers", env_servers))
72
- logger.info("Loaded MCP servers from MCP_SERVERS_JSON environment variable")
73
- except Exception as e:
74
- logger.warning("Failed to parse MCP_SERVERS_JSON: %s", e)
72
+ # MCP_SERVERS_JSON takes precedence (for Azure Container Apps)
73
+ mcp_env = os.environ.get("MCP_SERVERS_JSON")
74
+ if mcp_env:
75
+ try:
76
+ env_servers = json.loads(mcp_env)
77
+ servers.update(env_servers.get("mcpServers", env_servers))
78
+ logger.info("Loaded MCP servers from MCP_SERVERS_JSON environment variable")
79
+ except Exception as e:
80
+ logger.warning("Failed to parse MCP_SERVERS_JSON: %s", e)
81
+
82
+ # Ensure each MCP server inherits process env vars (the Claude CLI may
83
+ # not forward its own env to MCP server subprocesses).
84
+ for _name, cfg in servers.items():
85
+ if "env" not in cfg:
86
+ cfg["env"] = dict(os.environ)
87
+
88
+ _mcp_cache = servers
89
+ return servers
75
90
 
76
- # Ensure each MCP server inherits process env vars (the Claude CLI may
77
- # not forward its own env to MCP server subprocesses).
78
- for _name, cfg in servers.items():
79
- if "env" not in cfg:
80
- cfg["env"] = dict(os.environ)
81
91
 
82
- _mcp_cache = servers
83
- return servers
92
+ def warm_mcp_cache() -> None:
93
+ """Pre-load MCP server config so it's ready when agents spawn."""
94
+ _load_mcp_servers()
84
95
 
85
96
 
86
97
  def _create_runner(agent_type: str, permission_mode: str, mcp_servers: dict | None, env: dict | None = None) -> AgentRunner:
@@ -198,6 +209,11 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
198
209
  session_name = "session"
199
210
 
200
211
  instance = _registry.get_session_by_name(session_name)
212
+ # Also try matching by display_name (LLM may use different session keys
213
+ # for the same logical session, e.g. "calendar-today" vs "today-s-calendar")
214
+ if not instance and display_name:
215
+ instance = _registry.get_session_by_name(display_name)
216
+ is_new_session = instance is None
201
217
  if not instance:
202
218
  instance = _registry.create_session(session_name, agent_type=target_agent)
203
219
  # Update display_name if a better one was provided by the LLM
@@ -239,7 +255,13 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
239
255
  daemon=True,
240
256
  )
241
257
  thread.start()
242
- return f"Sent to session '{instance.display_name}'."
258
+ result = f"Sent to session '{instance.display_name}'."
259
+ if is_new_session:
260
+ all_sessions = registry.get_all_sessions()
261
+ if len(all_sessions) > 1:
262
+ names = [s.display_name for s in all_sessions]
263
+ result += f" (Active sessions: {', '.join(names)})"
264
+ return result
243
265
 
244
266
  thread = threading.Thread(
245
267
  target=_run_agent_background,
@@ -248,7 +270,13 @@ def _handle_send_to_session(message: str, session_name: str | None = None, agent
248
270
  )
249
271
  thread.start()
250
272
 
251
- return f"Sent to session '{instance.display_name}'."
273
+ result = f"Sent to session '{instance.display_name}'."
274
+ if is_new_session:
275
+ all_sessions = registry.get_all_sessions()
276
+ if len(all_sessions) > 1:
277
+ names = [s.display_name for s in all_sessions]
278
+ result += f" (Active sessions: {', '.join(names)})"
279
+ return result
252
280
 
253
281
 
254
282
  def _run_agent_background(instance: AgentInstance, message: str, registry: "InstanceRegistry") -> None:
@@ -9,6 +9,7 @@ timer while an agent is running.
9
9
  import logging
10
10
  import subprocess
11
11
  import threading
12
+ from concurrent.futures import ThreadPoolExecutor
12
13
  from pathlib import PurePosixPath
13
14
 
14
15
  logger = logging.getLogger(__name__)
@@ -16,16 +17,15 @@ logger = logging.getLogger(__name__)
16
17
  MAX_DEPTH = 10 # Maximum tree depth to prevent excessive nesting
17
18
 
18
19
 
19
- def _merge_base(worktree_path: str) -> str:
20
- """Get the merge-base commit between main and HEAD."""
21
- result = subprocess.run(
22
- ["git", "merge-base", "main", "HEAD"],
23
- cwd=worktree_path,
20
+ def _git(args: list[str], cwd: str) -> subprocess.CompletedProcess:
21
+ """Run a git command and return the CompletedProcess."""
22
+ return subprocess.run(
23
+ ["git", *args],
24
+ cwd=cwd,
24
25
  capture_output=True,
25
26
  text=True,
26
27
  timeout=10,
27
28
  )
28
- return result.stdout.strip() if result.returncode == 0 else "main"
29
29
 
30
30
 
31
31
  def _build_tree_node(path: str, status: str | None, is_folder: bool) -> dict:
@@ -112,26 +112,25 @@ def get_file_tree(worktree_path: str) -> dict:
112
112
  "summary": {"modified": N, "new": N, "deleted": N, "unchanged": N}}
113
113
  """
114
114
  try:
115
- # Get branch name
116
- branch_result = subprocess.run(
117
- ["git", "rev-parse", "--abbrev-ref", "HEAD"],
118
- cwd=worktree_path,
119
- capture_output=True,
120
- text=True,
121
- timeout=10,
122
- )
123
- root = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
115
+ # Run independent git commands in parallel, then diff after merge-base
116
+ with ThreadPoolExecutor(max_workers=4) as pool:
117
+ branch_fut = pool.submit(_git, ["rev-parse", "--abbrev-ref", "HEAD"], worktree_path)
118
+ base_fut = pool.submit(_git, ["merge-base", "main", "HEAD"], worktree_path)
119
+ untracked_fut = pool.submit(_git, ["ls-files", "--others", "--exclude-standard"], worktree_path)
120
+ all_files_fut = pool.submit(_git, ["ls-files"], worktree_path)
121
+
122
+ # Wait for merge-base, then submit diff (overlaps with ls-files)
123
+ base_result = base_fut.result()
124
+ base = base_result.stdout.strip() if base_result.returncode == 0 else "main"
125
+ diff_fut = pool.submit(_git, ["diff", "--name-status", base], worktree_path)
126
+
127
+ # Collect remaining results
128
+ branch_result = branch_fut.result()
129
+ diff_result = diff_fut.result()
130
+ untracked_result = untracked_fut.result()
131
+ all_files_result = all_files_fut.result()
124
132
 
125
- base = _merge_base(worktree_path)
126
-
127
- # Get modified/deleted files vs merge-base
128
- diff_result = subprocess.run(
129
- ["git", "diff", "--name-status", base],
130
- cwd=worktree_path,
131
- capture_output=True,
132
- text=True,
133
- timeout=10,
134
- )
133
+ root = branch_result.stdout.strip() if branch_result.returncode == 0 else "unknown"
135
134
 
136
135
  changed: dict[str, str] = {} # path -> status
137
136
  if diff_result.returncode == 0:
@@ -151,29 +150,12 @@ def get_file_tree(worktree_path: str) -> dict:
151
150
  elif status_code in ("R", "C"):
152
151
  changed[path] = "modified"
153
152
 
154
- # Get untracked files
155
- untracked_result = subprocess.run(
156
- ["git", "ls-files", "--others", "--exclude-standard"],
157
- cwd=worktree_path,
158
- capture_output=True,
159
- text=True,
160
- timeout=10,
161
- )
162
153
  if untracked_result.returncode == 0:
163
154
  for path in untracked_result.stdout.strip().splitlines():
164
155
  path = path.strip()
165
156
  if path and path not in changed:
166
157
  changed[path] = "new"
167
158
 
168
- # Get all tracked files to show complete project structure
169
- all_files_result = subprocess.run(
170
- ["git", "ls-files"],
171
- cwd=worktree_path,
172
- capture_output=True,
173
- text=True,
174
- timeout=10,
175
- )
176
-
177
159
  all_paths: dict[str, str] = {} # path -> status
178
160
  if all_files_result.returncode == 0:
179
161
  for f in all_files_result.stdout.strip().splitlines():
@@ -15,19 +15,12 @@ from voice_vibecoder.tools.dispatch import (
15
15
  from voice_vibecoder.worktrees import remove_worktree, resolve_worktree_path
16
16
 
17
17
 
18
- def get_startup_context() -> str:
19
- """Build a context string with current instances, agents, and worktrees for the system prompt."""
18
+ def get_active_instances_text() -> str:
19
+ """Return a text summary of active sessions and code instances for inline insertion."""
20
20
  from voice_vibecoder.code_providers.registry import get_agent
21
21
 
22
22
  parts = []
23
23
 
24
- enabled = _dispatch._enabled_agents
25
- if len(enabled) > 1:
26
- labels = [get_agent(a).label for a in enabled]
27
- parts.append(f"Enabled agents: {', '.join(labels)}")
28
- parts.append(f"Default agent: {get_agent(enabled[0]).label}")
29
- parts.append("Use the 'agent' parameter in send_to_agent/send_to_session to pick a specific agent.")
30
-
31
24
  if _dispatch._registry:
32
25
  code_instances = _dispatch._registry.get_all_code_instances()
33
26
  sessions = _dispatch._registry.get_all_sessions()
@@ -44,6 +37,24 @@ def get_startup_context() -> str:
44
37
  agent_label = get_agent(inst.agent_type).label
45
38
  parts.append(f" - {inst.display_name} ({inst.status.value}) [{agent_label}]")
46
39
 
40
+ if not parts:
41
+ return ""
42
+ return "\n".join(parts)
43
+
44
+
45
+ def get_startup_context() -> str:
46
+ """Build a context string with agent config and worktrees for the system prompt."""
47
+ from voice_vibecoder.code_providers.registry import get_agent
48
+
49
+ parts = []
50
+
51
+ enabled = _dispatch._enabled_agents
52
+ if len(enabled) > 1:
53
+ labels = [get_agent(a).label for a in enabled]
54
+ parts.append(f"Enabled agents: {', '.join(labels)}")
55
+ parts.append(f"Default agent: {get_agent(enabled[0]).label}")
56
+ parts.append("Use the 'agent' parameter in send_to_agent/send_to_session to pick a specific agent.")
57
+
47
58
  if _dispatch._repo_root:
48
59
  from voice_vibecoder.worktrees import list_worktrees
49
60
 
File without changes
File without changes