stravinsky 0.2.7__py3-none-any.whl → 0.2.40__py3-none-any.whl

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 (34) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/cli.py +84 -46
  3. mcp_bridge/auth/oauth.py +88 -63
  4. mcp_bridge/hooks/__init__.py +29 -8
  5. mcp_bridge/hooks/agent_reminder.py +61 -0
  6. mcp_bridge/hooks/auto_slash_command.py +186 -0
  7. mcp_bridge/hooks/comment_checker.py +136 -0
  8. mcp_bridge/hooks/context_monitor.py +58 -0
  9. mcp_bridge/hooks/empty_message_sanitizer.py +240 -0
  10. mcp_bridge/hooks/keyword_detector.py +122 -0
  11. mcp_bridge/hooks/manager.py +27 -8
  12. mcp_bridge/hooks/preemptive_compaction.py +157 -0
  13. mcp_bridge/hooks/session_recovery.py +186 -0
  14. mcp_bridge/hooks/todo_enforcer.py +75 -0
  15. mcp_bridge/hooks/truncator.py +1 -1
  16. mcp_bridge/native_hooks/stravinsky_mode.py +109 -0
  17. mcp_bridge/native_hooks/truncator.py +1 -1
  18. mcp_bridge/prompts/delphi.py +3 -2
  19. mcp_bridge/prompts/dewey.py +105 -21
  20. mcp_bridge/prompts/stravinsky.py +451 -127
  21. mcp_bridge/server.py +304 -38
  22. mcp_bridge/server_tools.py +21 -3
  23. mcp_bridge/tools/__init__.py +2 -1
  24. mcp_bridge/tools/agent_manager.py +313 -236
  25. mcp_bridge/tools/init.py +1 -1
  26. mcp_bridge/tools/model_invoke.py +534 -52
  27. mcp_bridge/tools/skill_loader.py +51 -47
  28. mcp_bridge/tools/task_runner.py +74 -30
  29. mcp_bridge/tools/templates.py +101 -12
  30. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/METADATA +6 -12
  31. stravinsky-0.2.40.dist-info/RECORD +57 -0
  32. stravinsky-0.2.7.dist-info/RECORD +0 -47
  33. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/WHEEL +0 -0
  34. {stravinsky-0.2.7.dist-info → stravinsky-0.2.40.dist-info}/entry_points.txt +0 -0
@@ -8,6 +8,7 @@ This replaces the simple model-only invocation with true agentic execution.
8
8
  import asyncio
9
9
  import json
10
10
  import os
11
+ import shutil
11
12
  import subprocess
12
13
  import signal
13
14
  import uuid
@@ -24,9 +25,10 @@ logger = logging.getLogger(__name__)
24
25
  @dataclass
25
26
  class AgentTask:
26
27
  """Represents a background agent task with full tool access."""
28
+
27
29
  id: str
28
30
  prompt: str
29
- agent_type: str # explore, librarian, frontend, etc.
31
+ agent_type: str # explore, dewey, frontend, delphi, etc.
30
32
  description: str
31
33
  status: str # pending, running, completed, failed, cancelled
32
34
  created_at: str
@@ -43,6 +45,7 @@ class AgentTask:
43
45
  @dataclass
44
46
  class AgentProgress:
45
47
  """Progress tracking for a running agent."""
48
+
46
49
  tool_calls: int = 0
47
50
  last_tool: Optional[str] = None
48
51
  last_message: Optional[str] = None
@@ -52,70 +55,79 @@ class AgentProgress:
52
55
  class AgentManager:
53
56
  """
54
57
  Manages background agent execution using Claude Code CLI.
55
-
58
+
56
59
  Key features:
57
60
  - Spawns agents with full tool access via `claude -p`
58
61
  - Tracks task status and progress
59
62
  - Persists state to .stravinsky/agents.json
60
63
  - Provides notification mechanism for task completion
61
64
  """
62
-
63
- CLAUDE_CLI = "/opt/homebrew/bin/claude"
64
-
65
+
66
+ # Dynamic CLI path - find claude in PATH, fallback to common locations
67
+ CLAUDE_CLI = shutil.which("claude") or "/opt/homebrew/bin/claude"
68
+
65
69
  def __init__(self, base_dir: Optional[str] = None):
70
+ # Initialize lock FIRST - used by _save_tasks and _load_tasks
71
+ self._lock = threading.RLock()
72
+
66
73
  if base_dir:
67
74
  self.base_dir = Path(base_dir)
68
75
  else:
69
76
  self.base_dir = Path.cwd() / ".stravinsky"
70
-
77
+
71
78
  self.agents_dir = self.base_dir / "agents"
72
79
  self.state_file = self.base_dir / "agents.json"
73
-
80
+
74
81
  self.base_dir.mkdir(parents=True, exist_ok=True)
75
82
  self.agents_dir.mkdir(parents=True, exist_ok=True)
76
-
83
+
77
84
  if not self.state_file.exists():
78
85
  self._save_tasks({})
79
-
86
+
80
87
  # In-memory tracking for running processes
81
88
  self._processes: Dict[str, subprocess.Popen] = {}
82
89
  self._notification_queue: Dict[str, List[AgentTask]] = {}
83
-
90
+
84
91
  def _load_tasks(self) -> Dict[str, Any]:
85
92
  """Load tasks from persistent storage."""
86
- try:
87
- with open(self.state_file, "r") as f:
88
- return json.load(f)
89
- except (json.JSONDecodeError, FileNotFoundError):
90
- return {}
91
-
93
+ with self._lock:
94
+ try:
95
+ if not self.state_file.exists():
96
+ return {}
97
+ with open(self.state_file, "r") as f:
98
+ return json.load(f)
99
+ except (json.JSONDecodeError, FileNotFoundError):
100
+ return {}
101
+
92
102
  def _save_tasks(self, tasks: Dict[str, Any]):
93
103
  """Save tasks to persistent storage."""
94
- with open(self.state_file, "w") as f:
95
- json.dump(tasks, f, indent=2)
96
-
104
+ with self._lock:
105
+ with open(self.state_file, "w") as f:
106
+ json.dump(tasks, f, indent=2)
107
+
97
108
  def _update_task(self, task_id: str, **kwargs):
98
109
  """Update a task's fields."""
99
- tasks = self._load_tasks()
100
- if task_id in tasks:
101
- tasks[task_id].update(kwargs)
102
- self._save_tasks(tasks)
103
-
110
+ with self._lock:
111
+ tasks = self._load_tasks()
112
+ if task_id in tasks:
113
+ tasks[task_id].update(kwargs)
114
+ self._save_tasks(tasks)
115
+
104
116
  def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
105
117
  """Get a task by ID."""
106
118
  tasks = self._load_tasks()
107
119
  return tasks.get(task_id)
108
-
120
+
109
121
  def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
110
122
  """List all tasks, optionally filtered by parent session."""
111
123
  tasks = self._load_tasks()
112
124
  task_list = list(tasks.values())
113
-
125
+
114
126
  if parent_session_id:
115
127
  task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
116
-
128
+
117
129
  return task_list
118
-
130
+
119
131
  def spawn(
120
132
  self,
121
133
  token_store: Any,
@@ -130,7 +142,7 @@ class AgentManager:
130
142
  ) -> str:
131
143
  """
132
144
  Spawn a new background agent.
133
-
145
+
134
146
  Args:
135
147
  prompt: The task prompt for the agent
136
148
  agent_type: Type of agent (explore, dewey, frontend, delphi)
@@ -139,12 +151,12 @@ class AgentManager:
139
151
  system_prompt: Optional custom system prompt
140
152
  model: Model to use (gemini-3-flash, claude, etc.)
141
153
  timeout: Maximum execution time in seconds
142
-
154
+
143
155
  Returns:
144
156
  Task ID for tracking
145
157
  """
146
158
  task_id = f"agent_{uuid.uuid4().hex[:8]}"
147
-
159
+
148
160
  task = AgentTask(
149
161
  id=task_id,
150
162
  prompt=prompt,
@@ -155,17 +167,20 @@ class AgentManager:
155
167
  parent_session_id=parent_session_id,
156
168
  timeout=timeout,
157
169
  )
158
-
170
+
159
171
  # Persist task
160
- tasks = self._load_tasks()
161
- tasks[task_id] = asdict(task)
162
- self._save_tasks(tasks)
163
-
172
+ with self._lock:
173
+ tasks = self._load_tasks()
174
+ tasks[task_id] = asdict(task)
175
+ self._save_tasks(tasks)
176
+
164
177
  # Start background execution
165
- self._execute_agent(task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout)
166
-
178
+ self._execute_agent(
179
+ task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
180
+ )
181
+
167
182
  return task_id
168
-
183
+
169
184
  def _execute_agent(
170
185
  self,
171
186
  task_id: str,
@@ -177,187 +192,164 @@ class AgentManager:
177
192
  thinking_budget: int = 0,
178
193
  timeout: int = 300,
179
194
  ):
180
- """Execute agent in background thread."""
181
-
195
+ """Execute agent using Claude CLI with full tool access.
196
+
197
+ Uses `claude -p` to spawn a background agent with complete tool access,
198
+ just like oh-my-opencode's Sisyphus implementation.
199
+ """
200
+
182
201
  def run_agent():
183
202
  log_file = self.agents_dir / f"{task_id}.log"
184
203
  output_file = self.agents_dir / f"{task_id}.out"
185
-
186
- self._update_task(
187
- task_id,
188
- status="running",
189
- started_at=datetime.now().isoformat()
190
- )
191
-
204
+
205
+ self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
206
+
192
207
  try:
193
- # Route based on model type
194
- if model.startswith("gemini"):
195
- # Use Gemini via invoke_gemini
196
- logger.info(f"[AgentManager] Spawning Gemini agent {task_id} with model {model}")
197
-
198
- # Build full prompt with system context
199
- full_prompt = prompt
200
- if system_prompt:
201
- full_prompt = f"{system_prompt}\n\n---\n\n{full_prompt}"
202
-
203
- # Import and call invoke_gemini
204
- import asyncio
205
- from .model_invoke import invoke_gemini
206
-
207
- # Run async in thread
208
- loop = asyncio.new_event_loop()
209
- asyncio.set_event_loop(loop)
210
- try:
211
- result = loop.run_until_complete(
212
- asyncio.wait_for(
213
- invoke_gemini(
214
- token_store=token_store,
215
- prompt=full_prompt,
216
- model=model,
217
- thinking_budget=thinking_budget
218
- ),
219
- timeout=timeout
220
- )
221
- )
222
- except asyncio.TimeoutError:
223
- self._update_task(
224
- task_id,
225
- status="failed",
226
- error=f"Task timed out after {timeout} seconds",
227
- completed_at=datetime.now().isoformat()
228
- )
229
- logger.error(f"[AgentManager] Gemini agent {task_id} timed out")
230
- return
231
- finally:
232
- loop.close()
233
-
234
- # Write output
235
- with open(output_file, "w") as f:
236
- f.write(result)
237
-
238
- self._update_task(
239
- task_id,
240
- status="completed",
241
- result=result,
242
- completed_at=datetime.now().isoformat()
243
- )
244
- logger.info(f"[AgentManager] Gemini agent {task_id} completed successfully")
245
-
246
- else:
247
- # Use Claude CLI for Claude models
248
- cmd = [
249
- self.CLAUDE_CLI,
250
- "-p", # Non-interactive print mode
251
- ]
252
-
253
- # Add system prompt if provided
254
- if system_prompt:
255
- cmd.extend(["--system-prompt", system_prompt])
256
-
257
- # Add the prompt
258
- cmd.append(prompt)
259
-
260
- logger.info(f"[AgentManager] Spawning Claude agent {task_id}: {' '.join(cmd[:5])}...")
261
-
262
- # Run Claude CLI
263
- process = subprocess.Popen(
264
- cmd,
265
- stdout=open(output_file, "w"),
266
- stderr=open(log_file, "w"),
267
- cwd=str(Path.cwd()),
268
- start_new_session=True,
269
- )
270
-
271
- self._processes[task_id] = process
272
- self._update_task(task_id, pid=process.pid)
273
-
274
- # Wait for completion with timeout
275
- try:
276
- return_code = process.wait(timeout=timeout)
277
- except subprocess.TimeoutExpired:
278
- try:
279
- os.killpg(os.getpgid(process.pid), signal.SIGTERM)
280
- except:
281
- process.kill()
282
-
283
- self._update_task(
284
- task_id,
285
- status="failed",
286
- error=f"Task timed out after {timeout} seconds",
287
- completed_at=datetime.now().isoformat()
288
- )
289
- logger.error(f"[AgentManager] Claude agent {task_id} timed out")
290
- return
291
-
292
- # Read output
293
- result = ""
294
- if output_file.exists():
295
- result = output_file.read_text()
296
-
297
- if return_code == 0:
208
+ # Prepare full prompt with system prompt if provided
209
+ full_prompt = prompt
210
+ if system_prompt:
211
+ full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
212
+
213
+ logger.info(f"[AgentManager] Spawning Claude CLI agent {task_id} ({agent_type})")
214
+
215
+ # Build Claude CLI command with full tool access
216
+ # Using `claude -p` for non-interactive mode with prompt
217
+ cmd = [
218
+ self.CLAUDE_CLI,
219
+ "-p",
220
+ full_prompt,
221
+ "--output-format",
222
+ "text",
223
+ ]
224
+
225
+ # NOTE: We intentionally do NOT pass --model to Claude CLI
226
+ # The agent_configs have Stravinsky MCP model names (gemini-3-pro-low, gpt-5.2)
227
+ # which Claude CLI doesn't recognize. Agents use Claude's default model
228
+ # and can invoke Stravinsky MCP tools (invoke_gemini, invoke_openai) if needed.
229
+
230
+ # Add system prompt file if we have one
231
+ if system_prompt:
232
+ system_file = self.agents_dir / f"{task_id}.system"
233
+ system_file.write_text(system_prompt)
234
+ cmd.extend(["--system-prompt", str(system_file)])
235
+
236
+ # Execute Claude CLI as subprocess with full tool access
237
+ logger.info(f"[AgentManager] Running: {' '.join(cmd[:3])}...")
238
+
239
+ # Use PIPE for stderr to capture it properly
240
+ # (Previously used file handle which was closed before process finished)
241
+ process = subprocess.Popen(
242
+ cmd,
243
+ stdout=subprocess.PIPE,
244
+ stderr=subprocess.PIPE,
245
+ text=True,
246
+ cwd=str(Path.cwd()),
247
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
248
+ start_new_session=True, # Allow process group management
249
+ )
250
+
251
+ # Track the process
252
+ self._processes[task_id] = process
253
+ self._update_task(task_id, pid=process.pid)
254
+
255
+ # Wait for completion with timeout
256
+ try:
257
+ stdout, stderr = process.communicate(timeout=timeout)
258
+ result = stdout.strip() if stdout else ""
259
+
260
+ # Write stderr to log file
261
+ if stderr:
262
+ log_file.write_text(stderr)
263
+
264
+ if process.returncode == 0:
265
+ output_file.write_text(result)
298
266
  self._update_task(
299
267
  task_id,
300
268
  status="completed",
301
269
  result=result,
302
- completed_at=datetime.now().isoformat()
270
+ completed_at=datetime.now().isoformat(),
303
271
  )
304
272
  logger.info(f"[AgentManager] Agent {task_id} completed successfully")
305
273
  else:
306
- error_log = ""
307
- if log_file.exists():
308
- error_log = log_file.read_text()
274
+ error_msg = f"Claude CLI exited with code {process.returncode}"
275
+ if stderr:
276
+ error_msg += f"\n{stderr}"
309
277
  self._update_task(
310
278
  task_id,
311
279
  status="failed",
312
- error=f"Exit code {return_code}: {error_log}",
313
- completed_at=datetime.now().isoformat()
280
+ error=error_msg,
281
+ completed_at=datetime.now().isoformat(),
314
282
  )
315
- logger.error(f"[AgentManager] Agent {task_id} failed: {error_log[:200]}")
316
-
283
+ logger.error(f"[AgentManager] Agent {task_id} failed: {error_msg}")
284
+
285
+ except subprocess.TimeoutExpired:
286
+ process.kill()
287
+ self._update_task(
288
+ task_id,
289
+ status="failed",
290
+ error=f"Agent timed out after {timeout}s",
291
+ completed_at=datetime.now().isoformat(),
292
+ )
293
+ logger.warning(f"[AgentManager] Agent {task_id} timed out")
294
+
295
+ except FileNotFoundError:
296
+ error_msg = f"Claude CLI not found at {self.CLAUDE_CLI}. Install with: npm install -g @anthropic-ai/claude-code"
297
+ log_file.write_text(error_msg)
298
+ self._update_task(
299
+ task_id,
300
+ status="failed",
301
+ error=error_msg,
302
+ completed_at=datetime.now().isoformat(),
303
+ )
304
+ logger.error(f"[AgentManager] {error_msg}")
305
+
317
306
  except Exception as e:
307
+ error_msg = str(e)
308
+ log_file.write_text(error_msg)
318
309
  self._update_task(
319
310
  task_id,
320
311
  status="failed",
321
- error=str(e),
322
- completed_at=datetime.now().isoformat()
312
+ error=error_msg,
313
+ completed_at=datetime.now().isoformat(),
323
314
  )
324
315
  logger.exception(f"[AgentManager] Agent {task_id} exception")
316
+
325
317
  finally:
326
318
  self._processes.pop(task_id, None)
327
319
  self._notify_completion(task_id)
328
-
320
+
329
321
  # Run in background thread
330
322
  thread = threading.Thread(target=run_agent, daemon=True)
331
323
  thread.start()
332
-
324
+
333
325
  def _notify_completion(self, task_id: str):
334
326
  """Queue notification for parent session."""
335
327
  task = self.get_task(task_id)
336
328
  if not task:
337
329
  return
338
-
330
+
339
331
  parent_id = task.get("parent_session_id")
340
332
  if parent_id:
341
333
  if parent_id not in self._notification_queue:
342
334
  self._notification_queue[parent_id] = []
343
-
335
+
344
336
  self._notification_queue[parent_id].append(task)
345
337
  logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
346
-
338
+
347
339
  def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
348
340
  """Get and clear pending notifications for a session."""
349
341
  notifications = self._notification_queue.pop(session_id, [])
350
342
  return notifications
351
-
343
+
352
344
  def cancel(self, task_id: str) -> bool:
353
345
  """Cancel a running agent task."""
354
346
  task = self.get_task(task_id)
355
347
  if not task:
356
348
  return False
357
-
349
+
358
350
  if task["status"] != "running":
359
351
  return False
360
-
352
+
361
353
  process = self._processes.get(task_id)
362
354
  if process:
363
355
  try:
@@ -369,31 +361,56 @@ class AgentManager:
369
361
  process.kill()
370
362
  except:
371
363
  pass
372
-
373
- self._update_task(
374
- task_id,
375
- status="cancelled",
376
- completed_at=datetime.now().isoformat()
377
- )
378
-
364
+
365
+ self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
366
+
379
367
  return True
380
-
368
+
369
+ def stop_all(self, clear_history: bool = False) -> int:
370
+ """
371
+ Stop all running agents and optionally clear task history.
372
+
373
+ Args:
374
+ clear_history: If True, also remove completed/failed tasks from history
375
+
376
+ Returns:
377
+ Number of tasks stopped/cleared
378
+ """
379
+ tasks = self._load_tasks()
380
+ stopped_count = 0
381
+
382
+ # Stop running tasks
383
+ for task_id, task in list(tasks.items()):
384
+ if task.get("status") == "running":
385
+ self.cancel(task_id)
386
+ stopped_count += 1
387
+
388
+ # Optionally clear history
389
+ if clear_history:
390
+ cleared = len(tasks)
391
+ self._save_tasks({})
392
+ self._processes.clear()
393
+ logger.info(f"[AgentManager] Cleared all {cleared} agent tasks")
394
+ return cleared
395
+
396
+ return stopped_count
397
+
381
398
  def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
382
399
  """
383
400
  Get output from an agent task.
384
-
401
+
385
402
  Args:
386
403
  task_id: The task ID
387
404
  block: If True, wait for completion
388
405
  timeout: Max seconds to wait if blocking
389
-
406
+
390
407
  Returns:
391
408
  Formatted task output/status
392
409
  """
393
410
  task = self.get_task(task_id)
394
411
  if not task:
395
412
  return f"Task {task_id} not found."
396
-
413
+
397
414
  if block and task["status"] == "running":
398
415
  # Poll for completion
399
416
  start = datetime.now()
@@ -402,11 +419,11 @@ class AgentManager:
402
419
  if task["status"] != "running":
403
420
  break
404
421
  asyncio.sleep(0.5)
405
-
422
+
406
423
  status = task["status"]
407
424
  description = task.get("description", "")
408
425
  agent_type = task.get("agent_type", "unknown")
409
-
426
+
410
427
  if status == "completed":
411
428
  result = task.get("result", "(no output)")
412
429
  return f"""✅ Agent Task Completed
@@ -417,7 +434,7 @@ class AgentManager:
417
434
 
418
435
  **Result**:
419
436
  {result}"""
420
-
437
+
421
438
  elif status == "failed":
422
439
  error = task.get("error", "(no error details)")
423
440
  return f"""❌ Agent Task Failed
@@ -428,14 +445,14 @@ class AgentManager:
428
445
 
429
446
  **Error**:
430
447
  {error}"""
431
-
448
+
432
449
  elif status == "cancelled":
433
450
  return f"""⚠️ Agent Task Cancelled
434
451
 
435
452
  **Task ID**: {task_id}
436
453
  **Agent**: {agent_type}
437
454
  **Description**: {description}"""
438
-
455
+
439
456
  else: # pending or running
440
457
  pid = task.get("pid", "N/A")
441
458
  started = task.get("started_at", "N/A")
@@ -448,46 +465,47 @@ class AgentManager:
448
465
  **Started**: {started}
449
466
 
450
467
  Use `agent_output` with block=true to wait for completion."""
451
-
468
+
452
469
  def get_progress(self, task_id: str, lines: int = 20) -> str:
453
470
  """
454
471
  Get real-time progress from a running agent's output.
455
-
472
+
456
473
  Args:
457
474
  task_id: The task ID
458
475
  lines: Number of lines to show from the end
459
-
476
+
460
477
  Returns:
461
478
  Recent output lines and status
462
479
  """
463
480
  task = self.get_task(task_id)
464
481
  if not task:
465
482
  return f"Task {task_id} not found."
466
-
483
+
467
484
  output_file = self.agents_dir / f"{task_id}.out"
468
485
  log_file = self.agents_dir / f"{task_id}.log"
469
-
486
+
470
487
  status = task["status"]
471
488
  description = task.get("description", "")
472
489
  agent_type = task.get("agent_type", "unknown")
473
490
  pid = task.get("pid")
474
-
491
+
475
492
  # Zombie Detection: If running but process is gone
476
493
  if status == "running" and pid:
477
494
  try:
478
495
  import psutil
496
+
479
497
  if not psutil.pid_exists(pid):
480
498
  status = "failed"
481
499
  self._update_task(
482
- task_id,
483
- status="failed",
500
+ task_id,
501
+ status="failed",
484
502
  error="Agent process died unexpectedly (Zombie detected)",
485
- completed_at=datetime.now().isoformat()
503
+ completed_at=datetime.now().isoformat(),
486
504
  )
487
505
  logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
488
506
  except ImportError:
489
- pass
490
-
507
+ pass
508
+
491
509
  # Read recent output
492
510
  output_content = ""
493
511
  if output_file.exists():
@@ -499,7 +517,7 @@ Use `agent_output` with block=true to wait for completion."""
499
517
  output_content = "\n".join(recent)
500
518
  except Exception:
501
519
  pass
502
-
520
+
503
521
  # Check log for errors
504
522
  log_content = ""
505
523
  if log_file.exists():
@@ -507,7 +525,7 @@ Use `agent_output` with block=true to wait for completion."""
507
525
  log_content = log_file.read_text().strip()
508
526
  except Exception:
509
527
  pass
510
-
528
+
511
529
  # Status emoji
512
530
  status_emoji = {
513
531
  "pending": "⏳",
@@ -516,7 +534,7 @@ Use `agent_output` with block=true to wait for completion."""
516
534
  "failed": "❌",
517
535
  "cancelled": "⚠️",
518
536
  }.get(status, "❓")
519
-
537
+
520
538
  result = f"""{status_emoji} **Agent Progress**
521
539
 
522
540
  **Task ID**: {task_id}
@@ -524,35 +542,40 @@ Use `agent_output` with block=true to wait for completion."""
524
542
  **Description**: {description}
525
543
  **Status**: {status}
526
544
  """
527
-
545
+
528
546
  if output_content:
529
547
  result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
530
548
  elif status == "running":
531
549
  result += "\n*Agent is working... no output yet.*"
532
-
550
+
533
551
  if log_content and status == "failed":
534
552
  # Truncate log if too long
535
553
  if len(log_content) > 500:
536
554
  log_content = log_content[:500] + "..."
537
555
  result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
538
-
556
+
539
557
  return result
540
558
 
541
559
 
542
560
  # Global manager instance
543
561
  _manager: Optional[AgentManager] = None
562
+ _manager_lock = threading.Lock()
544
563
 
545
564
 
546
565
  def get_manager() -> AgentManager:
547
566
  """Get or create the global AgentManager instance."""
548
567
  global _manager
549
568
  if _manager is None:
550
- _manager = AgentManager()
569
+ with _manager_lock:
570
+ # Double-check pattern to avoid race condition
571
+ if _manager is None:
572
+ _manager = AgentManager()
551
573
  return _manager
552
574
 
553
575
 
554
576
  # Tool interface functions
555
577
 
578
+
556
579
  async def agent_spawn(
557
580
  prompt: str,
558
581
  agent_type: str = "explore",
@@ -563,7 +586,7 @@ async def agent_spawn(
563
586
  ) -> str:
564
587
  """
565
588
  Spawn a background agent.
566
-
589
+
567
590
  Args:
568
591
  prompt: The task for the agent to perform
569
592
  agent_type: Type of agent (explore, dewey, frontend, delphi)
@@ -571,12 +594,12 @@ async def agent_spawn(
571
594
  model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
572
595
  thinking_budget: Reserved reasoning tokens
573
596
  timeout: Execution timeout in seconds
574
-
597
+
575
598
  Returns:
576
599
  Task ID and instructions
577
600
  """
578
601
  manager = get_manager()
579
-
602
+
580
603
  # Map agent types to system prompts
581
604
  system_prompts = {
582
605
  "explore": "You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions efficiently.",
@@ -607,26 +630,80 @@ ULTRATHINK MODE (when user says "ULTRATHINK" or "think harder"):
607
630
  2. Edge Case Analysis: What could go wrong and how we prevented it
608
631
  3. The Code: Optimized, bespoke, production-ready, utilizing existing libraries""",
609
632
  "delphi": "You are a strategic advisor. Provide architecture guidance, debugging assistance, and code review.",
633
+ "document_writer": """You are a Technical Documentation Specialist. Your expertise is creating clear, comprehensive documentation.
634
+
635
+ DOCUMENT TYPES YOU EXCEL AT:
636
+ - README files with proper structure
637
+ - API documentation with examples
638
+ - Architecture decision records (ADRs)
639
+ - User guides and tutorials
640
+ - Inline code documentation
641
+
642
+ DOCUMENTATION PRINCIPLES:
643
+ - Audience-first: Know who's reading and what they need
644
+ - Progressive disclosure: Overview → Details → Edge cases
645
+ - Examples over explanations: Show, don't just tell
646
+ - Keep it DRY: Reference rather than repeat
647
+ - Version awareness: Note when behavior differs across versions
648
+
649
+ RESPONSE FORMAT:
650
+ 1. Document type and target audience identified
651
+ 2. The documentation, properly formatted in markdown""",
652
+ "multimodal": """You interpret media files that cannot be read as plain text.
653
+
654
+ Your job: examine the attached file and extract ONLY what was requested.
655
+
656
+ CAPABILITIES:
657
+ - PDFs: extract text, structure, tables, data from specific sections
658
+ - Images: describe layouts, UI elements, text, diagrams, charts
659
+ - Diagrams: explain relationships, flows, architecture depicted
660
+ - Screenshots: analyze UI/UX, identify components, extract text
661
+
662
+ HOW YOU WORK:
663
+ 1. Receive a file path and a goal describing what to extract
664
+ 2. Read and analyze the file deeply using Gemini's vision capabilities
665
+ 3. Return ONLY the relevant extracted information
666
+ 4. The main agent never processes the raw file - you save context tokens
667
+
668
+ RESPONSE RULES:
669
+ - Return extracted information directly, no preamble
670
+ - If info not found, state clearly what's missing
671
+ - Be thorough on the goal, concise on everything else""",
610
672
  }
611
-
673
+
612
674
  system_prompt = system_prompts.get(agent_type, None)
613
-
675
+
676
+ # NOTE: All agents run via Claude CLI using Claude's default model.
677
+ # The agent_configs below are kept for documentation purposes only.
678
+ # Agents can invoke Stravinsky MCP tools (invoke_gemini, invoke_openai)
679
+ # within their prompts if they need to use other models.
680
+ #
681
+ # Agent model preferences (for reference - NOT passed to Claude CLI):
682
+ # - stravinsky: Claude Opus 4.5 (orchestration)
683
+ # - delphi: GPT-5.2 (strategic advice) - use invoke_openai
684
+ # - frontend: Gemini Pro High (UI/UX) - use invoke_gemini with thinking_budget
685
+ # - explore, dewey, document_writer, multimodal: Gemini Flash (fast) - use invoke_gemini
686
+
687
+ # Get token store for authentication
688
+ from ..auth.token_store import TokenStore
689
+
690
+ token_store = TokenStore()
691
+
614
692
  task_id = manager.spawn(
693
+ token_store=token_store,
615
694
  prompt=prompt,
616
695
  agent_type=agent_type,
617
696
  description=description or prompt[:50],
618
697
  system_prompt=system_prompt,
619
- model=model,
620
- thinking_budget=thinking_budget,
698
+ model=model, # Not used for Claude CLI, kept for API compatibility
699
+ thinking_budget=thinking_budget, # Not used for Claude CLI, kept for API compatibility
621
700
  timeout=timeout,
622
701
  )
623
-
702
+
624
703
  return f"""🚀 Background agent spawned successfully.
625
704
 
626
705
  **Task ID**: {task_id}
627
706
  **Agent Type**: {agent_type}
628
- **Model**: {model}
629
- **Thinking Budget**: {thinking_budget if thinking_budget > 0 else "N/A"}
630
707
  **Description**: {description or prompt[:50]}
631
708
 
632
709
  The agent is now running. Use:
@@ -638,11 +715,11 @@ The agent is now running. Use:
638
715
  async def agent_output(task_id: str, block: bool = False) -> str:
639
716
  """
640
717
  Get output from a background agent task.
641
-
718
+
642
719
  Args:
643
720
  task_id: The task ID from agent_spawn
644
721
  block: If True, wait for the task to complete (up to 30s)
645
-
722
+
646
723
  Returns:
647
724
  Task status and output
648
725
  """
@@ -657,48 +734,48 @@ async def agent_retry(
657
734
  ) -> str:
658
735
  """
659
736
  Retry a failed or timed-out background agent.
660
-
737
+
661
738
  Args:
662
739
  task_id: The ID of the task to retry
663
740
  new_prompt: Optional refined prompt for the retry
664
741
  new_timeout: Optional new timeout in seconds
665
-
742
+
666
743
  Returns:
667
744
  New Task ID and status
668
745
  """
669
746
  manager = get_manager()
670
747
  task = manager.get_task(task_id)
671
-
748
+
672
749
  if not task:
673
750
  return f"❌ Task {task_id} not found."
674
-
751
+
675
752
  if task["status"] in ["running", "pending"]:
676
753
  return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
677
-
754
+
678
755
  prompt = new_prompt or task["prompt"]
679
756
  timeout = new_timeout or task.get("timeout", 300)
680
-
757
+
681
758
  return await agent_spawn(
682
759
  prompt=prompt,
683
760
  agent_type=task["agent_type"],
684
761
  description=f"Retry of {task_id}: {task['description']}",
685
- timeout=timeout
762
+ timeout=timeout,
686
763
  )
687
764
 
688
765
 
689
766
  async def agent_cancel(task_id: str) -> str:
690
767
  """
691
768
  Cancel a running background agent.
692
-
769
+
693
770
  Args:
694
771
  task_id: The task ID to cancel
695
-
772
+
696
773
  Returns:
697
774
  Cancellation result
698
775
  """
699
776
  manager = get_manager()
700
777
  success = manager.cancel(task_id)
701
-
778
+
702
779
  if success:
703
780
  return f"✅ Agent task {task_id} has been cancelled."
704
781
  else:
@@ -712,18 +789,18 @@ async def agent_cancel(task_id: str) -> str:
712
789
  async def agent_list() -> str:
713
790
  """
714
791
  List all background agent tasks.
715
-
792
+
716
793
  Returns:
717
794
  Formatted list of tasks
718
795
  """
719
796
  manager = get_manager()
720
797
  tasks = manager.list_tasks()
721
-
798
+
722
799
  if not tasks:
723
800
  return "No background agent tasks found."
724
-
801
+
725
802
  lines = ["**Background Agent Tasks**", ""]
726
-
803
+
727
804
  for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
728
805
  status_emoji = {
729
806
  "pending": "⏳",
@@ -732,24 +809,24 @@ async def agent_list() -> str:
732
809
  "failed": "❌",
733
810
  "cancelled": "⚠️",
734
811
  }.get(t["status"], "❓")
735
-
812
+
736
813
  desc = t.get("description", t.get("prompt", "")[:40])
737
814
  lines.append(f"- {status_emoji} [{t['id']}] {t['agent_type']}: {desc}")
738
-
815
+
739
816
  return "\n".join(lines)
740
817
 
741
818
 
742
819
  async def agent_progress(task_id: str, lines: int = 20) -> str:
743
820
  """
744
821
  Get real-time progress from a running background agent.
745
-
822
+
746
823
  Shows the most recent output lines from the agent, useful for
747
824
  monitoring what the agent is currently doing.
748
-
825
+
749
826
  Args:
750
827
  task_id: The task ID from agent_spawn
751
828
  lines: Number of recent output lines to show (default 20)
752
-
829
+
753
830
  Returns:
754
831
  Recent agent output and status
755
832
  """