stravinsky 0.4.18__py3-none-any.whl → 0.4.66__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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (184) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/auth/__init__.py +16 -6
  3. mcp_bridge/auth/cli.py +202 -11
  4. mcp_bridge/auth/oauth.py +1 -2
  5. mcp_bridge/auth/openai_oauth.py +4 -7
  6. mcp_bridge/auth/token_store.py +0 -1
  7. mcp_bridge/cli/__init__.py +1 -1
  8. mcp_bridge/cli/install_hooks.py +503 -107
  9. mcp_bridge/cli/session_report.py +0 -3
  10. mcp_bridge/config/__init__.py +2 -2
  11. mcp_bridge/config/hook_config.py +3 -5
  12. mcp_bridge/config/rate_limits.py +108 -13
  13. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  14. mcp_bridge/hooks/__init__.py +14 -4
  15. mcp_bridge/hooks/agent_reminder.py +4 -4
  16. mcp_bridge/hooks/auto_slash_command.py +5 -5
  17. mcp_bridge/hooks/budget_optimizer.py +2 -2
  18. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  19. mcp_bridge/hooks/comment_checker.py +3 -4
  20. mcp_bridge/hooks/compaction.py +2 -2
  21. mcp_bridge/hooks/context.py +2 -1
  22. mcp_bridge/hooks/context_monitor.py +2 -2
  23. mcp_bridge/hooks/delegation_policy.py +85 -0
  24. mcp_bridge/hooks/directory_context.py +3 -3
  25. mcp_bridge/hooks/edit_recovery.py +3 -2
  26. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  27. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  28. mcp_bridge/hooks/events.py +160 -0
  29. mcp_bridge/hooks/git_noninteractive.py +4 -4
  30. mcp_bridge/hooks/keyword_detector.py +8 -10
  31. mcp_bridge/hooks/manager.py +35 -22
  32. mcp_bridge/hooks/notification_hook.py +13 -6
  33. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  34. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  35. mcp_bridge/hooks/parallel_execution.py +22 -10
  36. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  37. mcp_bridge/hooks/pre_compact.py +8 -9
  38. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  39. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  40. mcp_bridge/hooks/routing_notifications.py +80 -0
  41. mcp_bridge/hooks/rules_injector.py +11 -19
  42. mcp_bridge/hooks/session_idle.py +4 -4
  43. mcp_bridge/hooks/session_notifier.py +4 -4
  44. mcp_bridge/hooks/session_recovery.py +4 -5
  45. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  46. mcp_bridge/hooks/subagent_stop.py +1 -3
  47. mcp_bridge/hooks/task_validator.py +2 -2
  48. mcp_bridge/hooks/tmux_manager.py +7 -8
  49. mcp_bridge/hooks/todo_delegation.py +4 -1
  50. mcp_bridge/hooks/todo_enforcer.py +180 -10
  51. mcp_bridge/hooks/truncation_policy.py +37 -0
  52. mcp_bridge/hooks/truncator.py +1 -2
  53. mcp_bridge/metrics/cost_tracker.py +115 -0
  54. mcp_bridge/native_search.py +93 -0
  55. mcp_bridge/native_watcher.py +118 -0
  56. mcp_bridge/notifications.py +3 -4
  57. mcp_bridge/orchestrator/enums.py +11 -0
  58. mcp_bridge/orchestrator/router.py +165 -0
  59. mcp_bridge/orchestrator/state.py +32 -0
  60. mcp_bridge/orchestrator/visualization.py +14 -0
  61. mcp_bridge/orchestrator/wisdom.py +34 -0
  62. mcp_bridge/prompts/__init__.py +1 -8
  63. mcp_bridge/prompts/dewey.py +1 -1
  64. mcp_bridge/prompts/planner.py +2 -4
  65. mcp_bridge/prompts/stravinsky.py +53 -31
  66. mcp_bridge/proxy/__init__.py +0 -0
  67. mcp_bridge/proxy/client.py +70 -0
  68. mcp_bridge/proxy/model_server.py +157 -0
  69. mcp_bridge/routing/__init__.py +43 -0
  70. mcp_bridge/routing/config.py +250 -0
  71. mcp_bridge/routing/model_tiers.py +135 -0
  72. mcp_bridge/routing/provider_state.py +261 -0
  73. mcp_bridge/routing/task_classifier.py +190 -0
  74. mcp_bridge/server.py +363 -34
  75. mcp_bridge/server_tools.py +298 -6
  76. mcp_bridge/tools/__init__.py +19 -8
  77. mcp_bridge/tools/agent_manager.py +549 -799
  78. mcp_bridge/tools/background_tasks.py +13 -17
  79. mcp_bridge/tools/code_search.py +54 -51
  80. mcp_bridge/tools/continuous_loop.py +0 -1
  81. mcp_bridge/tools/dashboard.py +19 -0
  82. mcp_bridge/tools/find_code.py +296 -0
  83. mcp_bridge/tools/init.py +1 -0
  84. mcp_bridge/tools/list_directory.py +42 -0
  85. mcp_bridge/tools/lsp/__init__.py +8 -8
  86. mcp_bridge/tools/lsp/manager.py +51 -28
  87. mcp_bridge/tools/lsp/tools.py +98 -65
  88. mcp_bridge/tools/model_invoke.py +1047 -152
  89. mcp_bridge/tools/mux_client.py +75 -0
  90. mcp_bridge/tools/project_context.py +1 -2
  91. mcp_bridge/tools/query_classifier.py +132 -49
  92. mcp_bridge/tools/read_file.py +84 -0
  93. mcp_bridge/tools/replace.py +45 -0
  94. mcp_bridge/tools/run_shell_command.py +38 -0
  95. mcp_bridge/tools/search_enhancements.py +347 -0
  96. mcp_bridge/tools/semantic_search.py +677 -92
  97. mcp_bridge/tools/session_manager.py +0 -2
  98. mcp_bridge/tools/skill_loader.py +0 -1
  99. mcp_bridge/tools/task_runner.py +5 -7
  100. mcp_bridge/tools/templates.py +3 -3
  101. mcp_bridge/tools/tool_search.py +331 -0
  102. mcp_bridge/tools/write_file.py +29 -0
  103. mcp_bridge/update_manager.py +33 -37
  104. mcp_bridge/update_manager_pypi.py +6 -8
  105. mcp_bridge/utils/cache.py +82 -0
  106. mcp_bridge/utils/process.py +71 -0
  107. mcp_bridge/utils/session_state.py +51 -0
  108. mcp_bridge/utils/truncation.py +76 -0
  109. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
  110. stravinsky-0.4.66.dist-info/RECORD +198 -0
  111. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  112. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  113. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  114. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  115. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  116. stravinsky_claude_assets/agents/debugger.md +254 -0
  117. stravinsky_claude_assets/agents/delphi.md +495 -0
  118. stravinsky_claude_assets/agents/dewey.md +248 -0
  119. stravinsky_claude_assets/agents/explore.md +1198 -0
  120. stravinsky_claude_assets/agents/frontend.md +472 -0
  121. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  122. stravinsky_claude_assets/agents/momus.md +464 -0
  123. stravinsky_claude_assets/agents/research-lead.md +141 -0
  124. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  125. stravinsky_claude_assets/commands/delphi.md +9 -0
  126. stravinsky_claude_assets/commands/dewey.md +54 -0
  127. stravinsky_claude_assets/commands/git-master.md +112 -0
  128. stravinsky_claude_assets/commands/index.md +49 -0
  129. stravinsky_claude_assets/commands/publish.md +86 -0
  130. stravinsky_claude_assets/commands/review.md +73 -0
  131. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  132. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  133. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  134. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  135. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  136. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  137. stravinsky_claude_assets/commands/str/clean.md +97 -0
  138. stravinsky_claude_assets/commands/str/continue.md +38 -0
  139. stravinsky_claude_assets/commands/str/index.md +199 -0
  140. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  141. stravinsky_claude_assets/commands/str/search.md +205 -0
  142. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  143. stravinsky_claude_assets/commands/str/stats.md +71 -0
  144. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  145. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  146. stravinsky_claude_assets/commands/str/watch.md +45 -0
  147. stravinsky_claude_assets/commands/strav.md +53 -0
  148. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  149. stravinsky_claude_assets/commands/verify.md +60 -0
  150. stravinsky_claude_assets/commands/version.md +5 -0
  151. stravinsky_claude_assets/hooks/README.md +248 -0
  152. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  153. stravinsky_claude_assets/hooks/context.py +38 -0
  154. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  155. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  156. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  157. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  158. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  159. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  160. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  161. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  162. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  163. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  164. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  165. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  166. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  167. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  168. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  169. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  170. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  171. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  172. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  173. stravinsky_claude_assets/hooks/truncator.py +23 -0
  174. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  175. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  176. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  177. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  178. stravinsky_claude_assets/settings.json +152 -0
  179. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  180. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  181. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  182. stravinsky_claude_assets/task_dependencies.json +34 -0
  183. stravinsky-0.4.18.dist-info/RECORD +0 -88
  184. {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -5,82 +5,98 @@ Spawns background agents using Claude Code CLI with full tool access.
5
5
  This replaces the simple model-only invocation with true agentic execution.
6
6
  """
7
7
 
8
- import asyncio
9
8
  import json
9
+ import logging
10
10
  import os
11
11
  import shutil
12
- import subprocess
13
12
  import signal
13
+ import asyncio
14
+ import sys
15
+ import threading
14
16
  import time
15
- import uuid
16
- from dataclasses import asdict, dataclass, field
17
+ from dataclasses import asdict, dataclass
17
18
  from datetime import datetime
19
+ from enum import Enum
18
20
  from pathlib import Path
19
- from typing import Any, Dict, List, Optional
20
- import threading
21
- import logging
21
+ from typing import Any, Optional, List, Dict
22
+ import subprocess
23
+ from .mux_client import get_mux, MuxClient
24
+ try:
25
+ from . import semantic_search
26
+ except ImportError:
27
+ # Fallback or lazy import
28
+ semantic_search = None
22
29
 
23
30
  logger = logging.getLogger(__name__)
24
31
 
32
+
33
+ # Output formatting modes
34
+ class OutputMode(Enum):
35
+ """Control verbosity of agent spawn output."""
36
+
37
+ CLEAN = "clean" # Concise single-line output
38
+ VERBOSE = "verbose" # Full details with colors
39
+ SILENT = "silent" # No output to stdout (logs only)
40
+
41
+
25
42
  # Model routing configuration
26
- # Specialized agents call external models via MCP tools:
27
- # explore/dewey/document_writer/multimodal → invoke_gemini(gemini-3-flash)
28
- # frontend → invoke_gemini(gemini-3-pro-high)
29
- # delphi → invoke_openai(gpt-5.2)
30
- # Non-specialized coding tasks use Claude CLI with --model sonnet
31
43
  AGENT_MODEL_ROUTING = {
32
- # Specialized agents - no CLI model flag, they call invoke_* tools
33
44
  "explore": None,
34
45
  "dewey": None,
35
46
  "document_writer": None,
36
47
  "multimodal": None,
37
48
  "frontend": None,
38
49
  "delphi": None,
39
- "research-lead": None, # Hierarchical orchestrator using gemini-3-flash
40
- "implementation-lead": None, # Hierarchical orchestrator using haiku
41
- # Planner uses Opus for superior reasoning about dependencies and parallelization
50
+ "research-lead": None,
51
+ "implementation-lead": "sonnet",
52
+ "momus": None,
53
+ "comment_checker": None,
54
+ "debugger": "sonnet",
55
+ "code-reviewer": None,
42
56
  "planner": "opus",
43
- # Default for unknown agent types (coding tasks) - use Sonnet 4.5
44
57
  "_default": "sonnet",
45
58
  }
46
59
 
47
- # Cost tier classification (from oh-my-opencode pattern)
48
60
  AGENT_COST_TIERS = {
49
- "explore": "CHEAP", # Uses gemini-3-flash
50
- "dewey": "CHEAP", # Uses gemini-3-flash
51
- "document_writer": "CHEAP", # Uses gemini-3-flash
52
- "multimodal": "CHEAP", # Uses gemini-3-flash
53
- "research-lead": "CHEAP", # Uses gemini-3-flash
54
- "implementation-lead": "CHEAP", # Uses haiku
55
- "frontend": "MEDIUM", # Uses gemini-3-pro-high
56
- "delphi": "EXPENSIVE", # Uses gpt-5.2 (OpenAI GPT)
57
- "planner": "EXPENSIVE", # Uses Claude Opus 4.5
58
- "_default": "EXPENSIVE", # Claude Sonnet 4.5 via CLI
61
+ "explore": "CHEAP",
62
+ "dewey": "CHEAP",
63
+ "document_writer": "CHEAP",
64
+ "multimodal": "CHEAP",
65
+ "research-lead": "CHEAP",
66
+ "implementation-lead": "MEDIUM",
67
+ "momus": "CHEAP",
68
+ "comment_checker": "CHEAP",
69
+ "debugger": "MEDIUM",
70
+ "code-reviewer": "CHEAP",
71
+ "frontend": "MEDIUM",
72
+ "delphi": "EXPENSIVE",
73
+ "planner": "EXPENSIVE",
74
+ "_default": "EXPENSIVE",
59
75
  }
60
76
 
61
- # Display model names for output formatting (user-visible)
62
77
  AGENT_DISPLAY_MODELS = {
63
78
  "explore": "gemini-3-flash",
64
79
  "dewey": "gemini-3-flash",
65
80
  "document_writer": "gemini-3-flash",
66
81
  "multimodal": "gemini-3-flash",
67
82
  "research-lead": "gemini-3-flash",
68
- "implementation-lead": "haiku",
83
+ "implementation-lead": "claude-sonnet-4.5",
84
+ "momus": "gemini-3-flash",
85
+ "comment_checker": "gemini-3-flash",
86
+ "debugger": "claude-sonnet-4.5",
87
+ "code-reviewer": "gemini-3-flash",
69
88
  "frontend": "gemini-3-pro-high",
70
89
  "delphi": "gpt-5.2",
71
90
  "planner": "opus-4.5",
72
91
  "_default": "sonnet-4.5",
73
92
  }
74
93
 
75
- # Cost tier emoji indicators for visual differentiation
76
- # Colors indicate cost: 🟢 cheap/free, 🔵 medium, 🟣 expensive (GPT), 🟠 Claude
77
94
  COST_TIER_EMOJI = {
78
- "CHEAP": "🟢", # Free/cheap models (gemini-3-flash, haiku)
79
- "MEDIUM": "🔵", # Medium cost (gemini-3-pro-high)
80
- "EXPENSIVE": "🟣", # Expensive models (gpt-5.2, opus)
95
+ "CHEAP": "🟢",
96
+ "MEDIUM": "🔵",
97
+ "EXPENSIVE": "🟣",
81
98
  }
82
99
 
83
- # Model family indicators
84
100
  MODEL_FAMILY_EMOJI = {
85
101
  "gemini-3-flash": "🟢",
86
102
  "gemini-3-pro-high": "🔵",
@@ -90,14 +106,13 @@ MODEL_FAMILY_EMOJI = {
90
106
  "gpt-5.2": "🟣",
91
107
  }
92
108
 
93
- # ANSI color codes for terminal output
109
+
94
110
  class Colors:
95
111
  """ANSI color codes for colorized terminal output."""
112
+
96
113
  RESET = "\033[0m"
97
114
  BOLD = "\033[1m"
98
115
  DIM = "\033[2m"
99
-
100
- # Foreground colors
101
116
  BLACK = "\033[30m"
102
117
  RED = "\033[31m"
103
118
  GREEN = "\033[32m"
@@ -106,8 +121,6 @@ class Colors:
106
121
  MAGENTA = "\033[35m"
107
122
  CYAN = "\033[36m"
108
123
  WHITE = "\033[37m"
109
-
110
- # Bright foreground colors
111
124
  BRIGHT_BLACK = "\033[90m"
112
125
  BRIGHT_RED = "\033[91m"
113
126
  BRIGHT_GREEN = "\033[92m"
@@ -129,6 +142,90 @@ def get_model_emoji(model_name: str) -> str:
129
142
  return MODEL_FAMILY_EMOJI.get(model_name, "⚪")
130
143
 
131
144
 
145
+ ORCHESTRATOR_AGENTS = ["stravinsky", "research-lead", "implementation-lead"]
146
+ WORKER_AGENTS = [
147
+ "explore",
148
+ "dewey",
149
+ "delphi",
150
+ "frontend",
151
+ "debugger",
152
+ "code-reviewer",
153
+ "momus",
154
+ "comment_checker",
155
+ "document_writer",
156
+ "multimodal",
157
+ "planner",
158
+ ]
159
+
160
+ AGENT_TOOLS = {
161
+ "stravinsky": ["all"],
162
+ "research-lead": ["agent_spawn", "agent_output", "invoke_gemini", "Read", "Grep", "Glob"],
163
+ "implementation-lead": [
164
+ "agent_spawn",
165
+ "agent_output",
166
+ "lsp_diagnostics",
167
+ "Read",
168
+ "Edit",
169
+ "Write",
170
+ "Grep",
171
+ "Glob",
172
+ ],
173
+ "explore": [
174
+ "Read",
175
+ "Grep",
176
+ "Glob",
177
+ "Bash",
178
+ "semantic_search",
179
+ "ast_grep_search",
180
+ "lsp_workspace_symbols",
181
+ ],
182
+ "dewey": ["Read", "Grep", "Glob", "Bash", "WebSearch", "WebFetch"],
183
+ "frontend": ["Read", "Edit", "Write", "Grep", "Glob", "Bash", "invoke_gemini"],
184
+ "delphi": ["Read", "Grep", "Glob", "Bash", "invoke_openai"],
185
+ "debugger": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "lsp_hover", "ast_grep_search"],
186
+ "code-reviewer": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "ast_grep_search"],
187
+ "momus": ["Read", "Grep", "Glob", "Bash", "lsp_diagnostics", "ast_grep_search"],
188
+ "comment_checker": ["Read", "Grep", "Glob", "Bash", "ast_grep_search", "lsp_document_symbols"],
189
+ # Specialized agents
190
+ "document_writer": ["Read", "Write", "Grep", "Glob", "Bash", "invoke_gemini"],
191
+ "multimodal": ["Read", "invoke_gemini"],
192
+ "planner": ["Read", "Grep", "Glob", "Bash"],
193
+ }
194
+
195
+
196
+ def validate_agent_tools(agent_type: str, required_tools: list[str]) -> None:
197
+ if agent_type not in AGENT_TOOLS:
198
+ raise ValueError(
199
+ f"Unknown agent_type '{agent_type}'. Valid types: {list(AGENT_TOOLS.keys())}"
200
+ )
201
+
202
+ allowed_tools = AGENT_TOOLS[agent_type]
203
+ if "all" in allowed_tools:
204
+ return
205
+
206
+ missing_tools = [tool for tool in required_tools if tool not in allowed_tools]
207
+ if missing_tools:
208
+ raise ValueError(
209
+ f"Agent type '{agent_type}' does not have access to required tools: {missing_tools}\n"
210
+ f"Allowed tools for {agent_type}: {allowed_tools}"
211
+ )
212
+
213
+
214
+ def validate_agent_hierarchy(spawning_agent: str, target_agent: str) -> None:
215
+ if spawning_agent in ORCHESTRATOR_AGENTS:
216
+ return
217
+
218
+ if spawning_agent in WORKER_AGENTS and target_agent in ORCHESTRATOR_AGENTS:
219
+ raise ValueError(
220
+ f"Worker agent '{spawning_agent}' cannot spawn orchestrator agent '{target_agent}'."
221
+ )
222
+
223
+ if spawning_agent in WORKER_AGENTS and target_agent in WORKER_AGENTS:
224
+ raise ValueError(
225
+ f"Worker agent '{spawning_agent}' cannot spawn another worker agent '{target_agent}'."
226
+ )
227
+
228
+
132
229
  def colorize_agent_spawn_message(
133
230
  cost_emoji: str,
134
231
  agent_type: str,
@@ -136,20 +233,7 @@ def colorize_agent_spawn_message(
136
233
  description: str,
137
234
  task_id: str,
138
235
  ) -> str:
139
- """
140
- Create a colorized agent spawn message with ANSI color codes.
141
-
142
- Format:
143
- 🟢 explore:gemini-3-flash('Find auth...') ⏳
144
- task_id=agent_abc123
145
-
146
- With colors:
147
- 🟢 {CYAN}explore{RESET}:{YELLOW}gemini-3-flash{RESET}('{BOLD}Find auth...{RESET}') ⏳
148
- task_id={BRIGHT_BLACK}agent_abc123{RESET}
149
- """
150
236
  short_desc = (description or "")[:50].strip()
151
-
152
- # Build colorized message
153
237
  colored_message = (
154
238
  f"{cost_emoji} "
155
239
  f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
@@ -161,61 +245,63 @@ def colorize_agent_spawn_message(
161
245
  return colored_message
162
246
 
163
247
 
248
+ def format_spawn_output(
249
+ agent_type: str,
250
+ display_model: str,
251
+ task_id: str,
252
+ mode: OutputMode = OutputMode.CLEAN,
253
+ ) -> str:
254
+ if mode == OutputMode.SILENT:
255
+ return ""
256
+
257
+ cost_emoji = get_agent_emoji(agent_type)
258
+ if mode == OutputMode.CLEAN:
259
+ return (
260
+ f"{Colors.GREEN}✓{Colors.RESET} "
261
+ f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
262
+ f"{Colors.YELLOW}{display_model}{Colors.RESET} "
263
+ f"→ {Colors.CYAN}{task_id}{Colors.RESET}"
264
+ )
265
+ return ""
266
+
267
+
164
268
  @dataclass
165
269
  class AgentTask:
166
- """Represents a background agent task with full tool access."""
167
-
168
270
  id: str
169
271
  prompt: str
170
- agent_type: str # explore, dewey, frontend, delphi, etc.
272
+ agent_type: str
171
273
  description: str
172
- status: str # pending, running, completed, failed, cancelled
274
+ status: str
173
275
  created_at: str
174
- parent_session_id: Optional[str] = None
175
- started_at: Optional[str] = None
176
- completed_at: Optional[str] = None
177
- result: Optional[str] = None
178
- error: Optional[str] = None
179
- pid: Optional[int] = None
180
- timeout: int = 300 # Default 5 minutes
181
- progress: Optional[Dict[str, Any]] = None # tool calls, last update
182
-
183
-
184
- @dataclass
185
- class AgentProgress:
186
- """Progress tracking for a running agent."""
187
-
188
- tool_calls: int = 0
189
- last_tool: Optional[str] = None
190
- last_message: Optional[str] = None
191
- last_update: Optional[str] = None
276
+ parent_session_id: str | None = None
277
+ terminal_session_id: str | None = None
278
+ started_at: str | None = None
279
+ completed_at: str | None = None
280
+ result: str | None = None
281
+ error: str | None = None
282
+ pid: int | None = None
283
+ timeout: int = 300
284
+ progress: dict[str, Any] | None = None
192
285
 
193
286
 
194
287
  class AgentManager:
195
- """
196
- Manages background agent execution using Claude Code CLI.
197
-
198
- Key features:
199
- - Spawns agents with full tool access via `claude -p`
200
- - Tracks task status and progress
201
- - Persists state to .stravinsky/agents.json
202
- - Provides notification mechanism for task completion
203
- """
204
-
205
- # Dynamic CLI path - find claude in PATH, fallback to common locations
206
288
  CLAUDE_CLI = shutil.which("claude") or "/opt/homebrew/bin/claude"
207
289
 
208
- def __init__(self, base_dir: Optional[str] = None):
209
- # Initialize lock FIRST - used by _save_tasks and _load_tasks
290
+ def __init__(self, base_dir: str | None = None):
210
291
  self._lock = threading.RLock()
292
+ import uuid as uuid_module
211
293
 
294
+ self.session_id = os.environ.get(
295
+ "CLAUDE_CODE_SESSION_ID", f"pid_{os.getpid()}_{uuid_module.uuid4().hex[:8]}"
296
+ )
297
+
212
298
  if base_dir:
213
299
  self.base_dir = Path(base_dir)
214
300
  else:
215
301
  self.base_dir = Path.cwd() / ".stravinsky"
216
302
 
217
303
  self.agents_dir = self.base_dir / "agents"
218
- self.state_file = self.base_dir / "agents.json"
304
+ self.state_file = self.base_dir / f"agents_{self.session_id}.json"
219
305
 
220
306
  self.base_dir.mkdir(parents=True, exist_ok=True)
221
307
  self.agents_dir.mkdir(parents=True, exist_ok=True)
@@ -223,79 +309,148 @@ class AgentManager:
223
309
  if not self.state_file.exists():
224
310
  self._save_tasks({})
225
311
 
226
- # In-memory tracking for running processes
227
- self._processes: Dict[str, subprocess.Popen] = {}
228
- self._notification_queue: Dict[str, List[Dict[str, Any]]] = {}
312
+ self._processes: dict[str, Any] = {}
313
+ self._notification_queue: dict[str, list[dict[str, Any]]] = {}
314
+ self._tasks: dict[str, asyncio.Task] = {}
315
+ self._progress_monitors: dict[str, asyncio.Task] = {}
316
+ self._stop_monitors = asyncio.Event()
317
+
318
+ # Orchestrator Integration
319
+ self.orchestrator = None # Type: Optional[OrchestratorState]
320
+
321
+ try:
322
+ self._sync_cleanup(max_age_minutes=30)
323
+ except Exception:
324
+ pass
325
+
326
+ self._ensure_sidecar_running()
327
+
328
+ def _ensure_sidecar_running(self):
329
+ """Start the Go sidecar if not running."""
330
+ # Simple check: is socket present?
331
+ if os.path.exists("/tmp/stravinsky.sock"):
332
+ return
333
+
334
+ mux_path = Path.cwd() / "dist" / "stravinsky-mux"
335
+ if mux_path.exists():
336
+ try:
337
+ subprocess.Popen(
338
+ [str(mux_path)],
339
+ stdout=subprocess.DEVNULL,
340
+ stderr=subprocess.DEVNULL,
341
+ start_new_session=True
342
+ )
343
+ logger.info("Started stravinsky-mux sidecar")
344
+ # Wait briefly for socket
345
+ time.sleep(0.5)
346
+ except Exception as e:
347
+ logger.error(f"Failed to start sidecar: {e}")
348
+
349
+ def _sync_cleanup(self, max_age_minutes: int = 30):
350
+ tasks = self._load_tasks()
351
+ now = datetime.now()
352
+ removed_ids = []
353
+ for task_id, task in list(tasks.items()):
354
+ if task.get("status") in ["completed", "failed", "cancelled"]:
355
+ completed_at = task.get("completed_at")
356
+ if completed_at:
357
+ try:
358
+ completed_time = datetime.fromisoformat(completed_at)
359
+ if (now - completed_time).total_seconds() / 60 > max_age_minutes:
360
+ removed_ids.append(task_id)
361
+ del tasks[task_id]
362
+ except: continue
363
+ if removed_ids:
364
+ self._save_tasks(tasks)
229
365
 
230
- def _load_tasks(self) -> Dict[str, Any]:
231
- """Load tasks from persistent storage."""
366
+ def _load_tasks(self) -> dict[str, Any]:
232
367
  with self._lock:
233
368
  try:
234
369
  if not self.state_file.exists():
235
370
  return {}
236
- with open(self.state_file, "r") as f:
371
+ with open(self.state_file) as f:
237
372
  return json.load(f)
238
373
  except (json.JSONDecodeError, FileNotFoundError):
239
374
  return {}
240
375
 
241
- def _save_tasks(self, tasks: Dict[str, Any]):
242
- """Save tasks to persistent storage."""
243
- with self._lock:
244
- with open(self.state_file, "w") as f:
245
- json.dump(tasks, f, indent=2)
376
+ def _save_tasks(self, tasks: dict[str, Any]):
377
+ with self._lock, open(self.state_file, "w") as f:
378
+ json.dump(tasks, f, indent=2)
246
379
 
247
380
  def _update_task(self, task_id: str, **kwargs):
248
- """Update a task's fields."""
249
381
  with self._lock:
250
382
  tasks = self._load_tasks()
251
383
  if task_id in tasks:
252
384
  tasks[task_id].update(kwargs)
253
385
  self._save_tasks(tasks)
254
386
 
255
- def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
256
- """Get a task by ID."""
387
+ def get_task(self, task_id: str) -> dict[str, Any] | None:
257
388
  tasks = self._load_tasks()
258
389
  return tasks.get(task_id)
259
390
 
260
- def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
261
- """List all tasks, optionally filtered by parent session."""
391
+ def list_tasks(
392
+ self,
393
+ parent_session_id: str | None = None,
394
+ show_all: bool = True,
395
+ current_session_only: bool = True,
396
+ ) -> list[dict[str, Any]]:
262
397
  tasks = self._load_tasks()
263
398
  task_list = list(tasks.values())
264
-
399
+ if current_session_only:
400
+ task_list = [t for t in task_list if t.get("terminal_session_id") == self.session_id]
265
401
  if parent_session_id:
266
402
  task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
267
-
403
+ if not show_all:
404
+ task_list = [t for t in task_list if t.get("status") in ["running", "pending"]]
268
405
  return task_list
269
406
 
270
- def spawn(
407
+ async def spawn_async(
271
408
  self,
272
409
  token_store: Any,
273
410
  prompt: str,
274
411
  agent_type: str = "explore",
275
412
  description: str = "",
276
- parent_session_id: Optional[str] = None,
277
- system_prompt: Optional[str] = None,
413
+ parent_session_id: str | None = None,
414
+ system_prompt: str | None = None,
278
415
  model: str = "gemini-3-flash",
279
416
  thinking_budget: int = 0,
280
417
  timeout: int = 300,
418
+ semantic_first: bool = False,
281
419
  ) -> str:
282
- """
283
- Spawn a new background agent.
284
-
285
- Args:
286
- prompt: The task prompt for the agent
287
- agent_type: Type of agent (explore, dewey, frontend, delphi)
288
- description: Short description for status display
289
- parent_session_id: Optional parent session for notifications
290
- system_prompt: Optional custom system prompt
291
- model: Model to use (gemini-3-flash, claude, etc.)
292
- timeout: Maximum execution time in seconds
293
-
294
- Returns:
295
- Task ID for tracking
296
- """
297
- import uuid as uuid_module # Local import for MCP context
420
+ # Orchestrator Logic
421
+ if self.orchestrator:
422
+ logger.info(f"Spawning agent {agent_type} in phase {self.orchestrator.current_phase}")
423
+ # Example: If in PLAN phase, inject wisdom automatically
424
+ from ..orchestrator.enums import OrchestrationPhase
425
+ if self.orchestrator.current_phase == OrchestrationPhase.PLAN:
426
+ from ..orchestrator.wisdom import WisdomLoader
427
+ wisdom = WisdomLoader().load_wisdom()
428
+ if wisdom:
429
+ prompt = f"## PROJECT WISDOM\n{wisdom}\n\n---\n\n{prompt}"
430
+
431
+ # Semantic First Context Injection
432
+ if semantic_first and semantic_search:
433
+ try:
434
+ # Run search in thread to avoid blocking loop
435
+ results = await asyncio.to_thread(
436
+ semantic_search.search,
437
+ query=prompt,
438
+ n_results=5,
439
+ project_path=str(self.base_dir.parent)
440
+ )
441
+ if results and "No results" not in results and "Error" not in results:
442
+ prompt = (
443
+ f"## 🧠 SEMANTIC CONTEXT (AUTO-INJECTED)\n"
444
+ f"The following code snippets were found in the vector index based on your task:\n\n"
445
+ f"{results}\n\n"
446
+ f"---\n\n"
447
+ f"## 📋 YOUR TASK\n"
448
+ f"{prompt}"
449
+ )
450
+ except Exception as e:
451
+ logger.error(f"Semantic context injection failed: {e}")
298
452
 
453
+ import uuid as uuid_module
299
454
  task_id = f"agent_{uuid_module.uuid4().hex[:8]}"
300
455
 
301
456
  task = AgentTask(
@@ -306,805 +461,400 @@ class AgentManager:
306
461
  status="pending",
307
462
  created_at=datetime.now().isoformat(),
308
463
  parent_session_id=parent_session_id,
464
+ terminal_session_id=self.session_id,
309
465
  timeout=timeout,
310
466
  )
311
467
 
312
- # Persist task
313
468
  with self._lock:
314
469
  tasks = self._load_tasks()
315
470
  tasks[task_id] = asdict(task)
316
471
  self._save_tasks(tasks)
317
472
 
318
- # Start background execution
319
- self._execute_agent(
320
- task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
473
+ task_obj = asyncio.create_task(
474
+ self._execute_agent_async(
475
+ task_id, token_store, prompt, agent_type, system_prompt, model, thinking_budget, timeout
476
+ )
321
477
  )
478
+ self._tasks[task_id] = task_obj
322
479
 
323
480
  return task_id
324
481
 
325
- def _execute_agent(
482
+ def spawn(self, *args, **kwargs) -> str:
483
+ try:
484
+ loop = asyncio.get_running_loop()
485
+ task_id_ref = [None]
486
+ async def wrap():
487
+ task_id_ref[0] = await self.spawn_async(*args, **kwargs)
488
+
489
+ thread = threading.Thread(target=lambda: asyncio.run(wrap()))
490
+ thread.start()
491
+ thread.join()
492
+ return task_id_ref[0]
493
+ except RuntimeError:
494
+ return asyncio.run(self.spawn_async(*args, **kwargs))
495
+
496
+ async def _execute_agent_async(
326
497
  self,
327
498
  task_id: str,
328
499
  token_store: Any,
329
500
  prompt: str,
330
501
  agent_type: str,
331
- system_prompt: Optional[str] = None,
502
+ system_prompt: str | None = None,
332
503
  model: str = "gemini-3-flash",
333
504
  thinking_budget: int = 0,
334
505
  timeout: int = 300,
335
506
  ):
336
- """Execute agent using Claude CLI with full tool access.
337
-
338
- Uses `claude -p` to spawn a background agent with complete tool access,
339
- just like oh-my-opencode's Sisyphus implementation.
340
- """
341
-
342
- def run_agent():
343
- log_file = self.agents_dir / f"{task_id}.log"
344
- output_file = self.agents_dir / f"{task_id}.out"
345
-
346
- self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
507
+ self.agents_dir.mkdir(parents=True, exist_ok=True)
508
+ log_file = self.agents_dir / f"{task_id}.log"
509
+ output_file = self.agents_dir / f"{task_id}.out"
347
510
 
511
+ self._update_task(task_id, status="running", started_at=datetime.now().isoformat())
512
+
513
+ try:
514
+ full_prompt = prompt
515
+ if system_prompt:
516
+ full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
517
+
518
+ cmd = [
519
+ self.CLAUDE_CLI,
520
+ "-p",
521
+ full_prompt,
522
+ "--output-format",
523
+ "text",
524
+ "--dangerously-skip-permissions",
525
+ ]
526
+
527
+ cli_model = AGENT_MODEL_ROUTING.get(agent_type, AGENT_MODEL_ROUTING.get("_default", "sonnet"))
528
+ if cli_model:
529
+ cmd.extend(["--model", cli_model])
530
+
531
+ if thinking_budget and thinking_budget > 0:
532
+ cmd.extend(["--thinking-budget", str(thinking_budget)])
533
+
534
+ if system_prompt:
535
+ system_file = self.agents_dir / f"{task_id}.system"
536
+ system_file.write_text(system_prompt)
537
+ cmd.extend(["--system-prompt", str(system_file)])
538
+
539
+ logger.info(f"[AgentManager] Spawning {task_id}: {' '.join(cmd[:3])}...")
540
+
541
+ process = await asyncio.create_subprocess_exec(
542
+ *cmd,
543
+ stdin=asyncio.subprocess.DEVNULL,
544
+ stdout=asyncio.subprocess.PIPE,
545
+ stderr=asyncio.subprocess.PIPE,
546
+ cwd=str(Path.cwd()),
547
+ env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
548
+ start_new_session=True,
549
+ )
550
+
551
+ self._processes[task_id] = process
552
+ self._update_task(task_id, pid=process.pid)
553
+
554
+ # Streaming read loop for Mux
555
+ stdout_buffer = []
556
+ stderr_buffer = []
557
+ mux = MuxClient(task_id)
558
+ mux.connect()
559
+
560
+ async def read_stream(stream, buffer, stream_name):
561
+ while True:
562
+ line = await stream.readline()
563
+ if not line:
564
+ break
565
+ decoded = line.decode('utf-8', errors='replace')
566
+ buffer.append(decoded)
567
+ mux.log(decoded.strip(), stream_name)
568
+
348
569
  try:
349
- # Prepare full prompt with system prompt if provided
350
- full_prompt = prompt
351
- if system_prompt:
352
- full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
353
-
354
- logger.info(f"[AgentManager] Spawning Claude CLI agent {task_id} ({agent_type})")
355
-
356
- # Build Claude CLI command with full tool access
357
- # Using `claude -p` for non-interactive mode with prompt
358
- cmd = [
359
- self.CLAUDE_CLI,
360
- "-p",
361
- full_prompt,
362
- "--output-format",
363
- "text",
364
- "--dangerously-skip-permissions", # Critical: bypass permission prompts
365
- ]
366
-
367
- # Model routing:
368
- # - Specialized agents (explore/dewey/etc): None = use CLI default, they call invoke_*
369
- # - Unknown agent types (coding tasks): Use Sonnet 4.5
370
- if agent_type in AGENT_MODEL_ROUTING:
371
- cli_model = AGENT_MODEL_ROUTING[agent_type] # None for specialized
372
- else:
373
- cli_model = AGENT_MODEL_ROUTING.get("_default", "sonnet")
374
-
375
- if cli_model:
376
- cmd.extend(["--model", cli_model])
377
- logger.info(f"[AgentManager] Using --model {cli_model} for {agent_type} agent")
378
-
379
- # Add system prompt file if we have one
380
- if system_prompt:
381
- system_file = self.agents_dir / f"{task_id}.system"
382
- system_file.write_text(system_prompt)
383
- cmd.extend(["--system-prompt", str(system_file)])
384
-
385
- # Execute Claude CLI as subprocess with full tool access
386
- logger.info(f"[AgentManager] Running: {' '.join(cmd[:3])}...")
387
-
388
- # Use PIPE for stderr to capture it properly
389
- # (Previously used file handle which was closed before process finished)
390
- process = subprocess.Popen(
391
- cmd,
392
- stdin=subprocess.DEVNULL, # Critical: prevent stdin blocking
393
- stdout=subprocess.PIPE,
394
- stderr=subprocess.PIPE,
395
- text=True,
396
- cwd=str(Path.cwd()),
397
- env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
398
- start_new_session=True, # Allow process group management
570
+ await asyncio.wait_for(
571
+ asyncio.gather(
572
+ read_stream(process.stdout, stdout_buffer, "stdout"),
573
+ read_stream(process.stderr, stderr_buffer, "stderr"),
574
+ process.wait()
575
+ ),
576
+ timeout=timeout
399
577
  )
400
-
401
- # Track the process
402
- self._processes[task_id] = process
403
- self._update_task(task_id, pid=process.pid)
404
-
405
- # Wait for completion with timeout
578
+ except asyncio.TimeoutError:
406
579
  try:
407
- stdout, stderr = process.communicate(timeout=timeout)
408
- result = stdout.strip() if stdout else ""
409
-
410
- # Write stderr to log file
411
- if stderr:
412
- log_file.write_text(stderr)
413
-
414
- if process.returncode == 0:
415
- output_file.write_text(result)
416
- self._update_task(
417
- task_id,
418
- status="completed",
419
- result=result,
420
- completed_at=datetime.now().isoformat(),
421
- )
422
- logger.info(f"[AgentManager] Agent {task_id} completed successfully")
423
- else:
424
- error_msg = f"Claude CLI exited with code {process.returncode}"
425
- if stderr:
426
- error_msg += f"\n{stderr}"
427
- self._update_task(
428
- task_id,
429
- status="failed",
430
- error=error_msg,
431
- completed_at=datetime.now().isoformat(),
432
- )
433
- logger.error(f"[AgentManager] Agent {task_id} failed: {error_msg}")
434
-
435
- except subprocess.TimeoutExpired:
436
- process.kill()
437
- self._update_task(
438
- task_id,
439
- status="failed",
440
- error=f"Agent timed out after {timeout}s",
441
- completed_at=datetime.now().isoformat(),
442
- )
443
- logger.warning(f"[AgentManager] Agent {task_id} timed out")
444
-
445
- except FileNotFoundError:
446
- error_msg = f"Claude CLI not found at {self.CLAUDE_CLI}. Install with: npm install -g @anthropic-ai/claude-code"
447
- log_file.write_text(error_msg)
580
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
581
+ except: pass
582
+ # Clean up streams
583
+ await process.wait()
584
+ error_msg = f"Timed out after {timeout}s"
585
+ output_file.write_text(f"❌ TIMEOUT: {error_msg}")
586
+ self._update_task(task_id, status="failed", error=error_msg, completed_at=datetime.now().isoformat())
587
+ return
588
+
589
+ stdout = "".join(stdout_buffer)
590
+ stderr = "".join(stderr_buffer)
591
+
592
+ if stderr:
593
+ log_file.write_text(stderr)
594
+
595
+ if process.returncode == 0:
596
+ output_file.write_text(stdout)
448
597
  self._update_task(
449
598
  task_id,
450
- status="failed",
451
- error=error_msg,
599
+ status="completed",
600
+ result=stdout.strip(),
452
601
  completed_at=datetime.now().isoformat(),
453
602
  )
454
- logger.error(f"[AgentManager] {error_msg}")
455
-
456
- except Exception as e:
457
- error_msg = str(e)
458
- log_file.write_text(error_msg)
603
+ else:
604
+ error_msg = f"Exit code {process.returncode}\n{stderr}"
605
+ output_file.write_text(f"❌ ERROR: {error_msg}")
459
606
  self._update_task(
460
607
  task_id,
461
608
  status="failed",
462
609
  error=error_msg,
463
610
  completed_at=datetime.now().isoformat(),
464
611
  )
465
- logger.exception(f"[AgentManager] Agent {task_id} exception")
466
612
 
467
- finally:
468
- self._processes.pop(task_id, None)
469
- self._notify_completion(task_id)
613
+ except asyncio.CancelledError:
614
+
615
+
470
616
 
471
- # Run in background thread
472
- thread = threading.Thread(target=run_agent, daemon=True)
473
- thread.start()
617
+
618
+ try:
619
+ if task_id in self._processes:
620
+ proc = self._processes[task_id]
621
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
622
+ await proc.wait()
623
+ except: pass
624
+ raise
625
+ except Exception as e:
626
+ error_msg = str(e)
627
+ output_file.write_text(f"❌ EXCEPTION: {error_msg}")
628
+ self._update_task(task_id, status="failed", error=error_msg, completed_at=datetime.now().isoformat())
629
+ finally:
630
+ self._processes.pop(task_id, None)
631
+ self._tasks.pop(task_id, None)
632
+ self._notify_completion(task_id)
474
633
 
475
634
  def _notify_completion(self, task_id: str):
476
- """Queue notification for parent session."""
477
635
  task = self.get_task(task_id)
478
- if not task:
479
- return
480
-
481
- parent_id = task.get("parent_session_id")
482
- if parent_id:
636
+ if task and task.get("parent_session_id"):
637
+ parent_id = task["parent_session_id"]
483
638
  if parent_id not in self._notification_queue:
484
639
  self._notification_queue[parent_id] = []
485
-
486
640
  self._notification_queue[parent_id].append(task)
487
- logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
488
641
 
489
- def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
490
- """Get and clear pending notifications for a session."""
491
- notifications = self._notification_queue.pop(session_id, [])
492
- return notifications
642
+ async def _monitor_progress_async(self, task_id: str, interval: int = 10):
643
+ task = self.get_task(task_id)
644
+ if not task: return
645
+ start_time = datetime.fromisoformat(task.get("started_at") or datetime.now().isoformat())
646
+
647
+ while not self._stop_monitors.is_set():
648
+ task = self.get_task(task_id)
649
+ if not task or task["status"] not in ["running", "pending"]:
650
+ # Final status reporting...
651
+ break
652
+
653
+ elapsed = int((datetime.now() - start_time).total_seconds())
654
+ sys.stderr.write(f"{Colors.YELLOW}⏳{Colors.RESET} {Colors.CYAN}{task_id}{Colors.RESET} running ({elapsed}s)...\n")
655
+ sys.stderr.flush()
656
+
657
+ try:
658
+ await asyncio.wait_for(self._stop_monitors.wait(), timeout=interval)
659
+ break
660
+ except asyncio.TimeoutError:
661
+ continue
493
662
 
494
663
  def cancel(self, task_id: str) -> bool:
495
- """Cancel a running agent task."""
496
664
  task = self.get_task(task_id)
497
- if not task:
498
- return False
499
-
500
- if task["status"] != "running":
665
+ if not task or task["status"] not in ["pending", "running"]:
501
666
  return False
502
667
 
503
668
  process = self._processes.get(task_id)
504
669
  if process:
505
670
  try:
506
- os.killpg(os.getpgid(process.pid), signal.SIGTERM)
507
- process.wait(timeout=5)
508
- except Exception as e:
509
- logger.warning(f"[AgentManager] Failed to kill process for {task_id}: {e}")
510
- try:
511
- process.kill()
512
- except:
513
- pass
514
-
671
+ if hasattr(process, 'pid'):
672
+ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
673
+ except: pass
674
+
675
+ async_task = self._tasks.get(task_id)
676
+ if async_task:
677
+ async_task.cancel()
678
+
515
679
  self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
516
-
517
680
  return True
518
681
 
519
- def stop_all(self, clear_history: bool = False) -> int:
520
- """
521
- Stop all running agents and optionally clear task history.
522
-
523
- Args:
524
- clear_history: If True, also remove completed/failed tasks from history
525
-
526
- Returns:
527
- Number of tasks stopped/cleared
528
- """
682
+ async def stop_all_async(self, clear_history: bool = False) -> int:
529
683
  tasks = self._load_tasks()
530
684
  stopped_count = 0
531
-
532
- # Stop running tasks
533
685
  for task_id, task in list(tasks.items()):
534
- if task.get("status") == "running":
535
- self.cancel(task_id)
536
- stopped_count += 1
537
-
538
- # Optionally clear history
686
+ status = task.get("status")
687
+ if status in ["pending", "running"]:
688
+ if self.cancel(task_id):
689
+ stopped_count += 1
690
+
691
+ self._stop_monitors.set()
692
+
693
+ if self._tasks:
694
+ await asyncio.gather(*self._tasks.values(), return_exceptions=True)
695
+ if self._progress_monitors:
696
+ await asyncio.gather(*self._progress_monitors.values(), return_exceptions=True)
697
+
539
698
  if clear_history:
540
699
  cleared = len(tasks)
541
700
  self._save_tasks({})
542
701
  self._processes.clear()
543
- logger.info(f"[AgentManager] Cleared all {cleared} agent tasks")
702
+ self._tasks.clear()
703
+ self._progress_monitors.clear()
544
704
  return cleared
545
-
546
705
  return stopped_count
547
706
 
548
- def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
549
- """
550
- Get output from an agent task.
551
-
552
- Args:
553
- task_id: The task ID
554
- block: If True, wait for completion
555
- timeout: Max seconds to wait if blocking
556
-
557
- Returns:
558
- Formatted task output/status
559
- """
707
+ def stop_all(self, clear_history: bool = False) -> int:
708
+ try:
709
+ return asyncio.run(self.stop_all_async(clear_history))
710
+ except RuntimeError:
711
+ # Loop already running, use a thread
712
+ res = [0]
713
+ def wrap(): res[0] = asyncio.run(self.stop_all_async(clear_history))
714
+ t = threading.Thread(target=wrap)
715
+ t.start()
716
+ t.join()
717
+ return res[0]
718
+
719
+ def cleanup(self, max_age_minutes: int = 30, statuses: list[str] | None = None) -> dict:
720
+ if statuses is None: statuses = ["completed", "failed", "cancelled"]
721
+ tasks = self._load_tasks()
722
+ now = datetime.now()
723
+ removed_ids = []
724
+ for task_id, task in list(tasks.items()):
725
+ if task.get("status") in statuses:
726
+ completed_at = task.get("completed_at")
727
+ if completed_at:
728
+ try:
729
+ completed_time = datetime.fromisoformat(completed_at)
730
+ if (now - completed_time).total_seconds() / 60 > max_age_minutes:
731
+ removed_ids.append(task_id)
732
+ del tasks[task_id]
733
+ for ext in [".log", ".out", ".system"]:
734
+ (self.agents_dir / f"{task_id}{ext}").unlink(missing_ok=True)
735
+ except: continue
736
+ if removed_ids: self._save_tasks(tasks)
737
+ return {"removed": len(removed_ids), "task_ids": removed_ids, "summary": f"Removed {len(removed_ids)} agents"}
738
+
739
+ async def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0, auto_cleanup: bool = False) -> str:
560
740
  task = self.get_task(task_id)
561
- if not task:
562
- return f"Task {task_id} not found."
741
+ if not task: return f"Task {task_id} not found."
563
742
 
564
- if block and task["status"] == "running":
565
- # Poll for completion
566
- start = datetime.now()
567
- while (datetime.now() - start).total_seconds() < timeout:
743
+ if block and task["status"] in ["pending", "running"]:
744
+ start = time.time()
745
+ while (time.time() - start) < timeout:
568
746
  task = self.get_task(task_id)
569
- if not task or task["status"] != "running":
570
- break
571
- time.sleep(0.5)
572
-
573
- # Refresh task state after potential blocking wait
574
- if not task:
575
- return f"Task {task_id} not found."
747
+ if not task or task["status"] not in ["pending", "running"]: break
748
+ await asyncio.sleep(0.5)
576
749
 
750
+ task = self.get_task(task_id)
577
751
  status = task["status"]
578
- description = task.get("description", "")
579
752
  agent_type = task.get("agent_type", "unknown")
580
-
581
- # Get cost-tier emoji for visual differentiation
582
753
  cost_emoji = get_agent_emoji(agent_type)
583
754
  display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
584
755
 
585
756
  if status == "completed":
586
- result = task.get("result", "(no output)")
587
- return f"""{cost_emoji} {Colors.BRIGHT_GREEN}✅ Agent Task Completed{Colors.RESET}
588
-
589
- **Task ID**: {Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}
590
- **Agent**: {Colors.CYAN}{agent_type}{Colors.RESET}:{Colors.YELLOW}{display_model}{Colors.RESET}('{Colors.BOLD}{description}{Colors.RESET}')
591
-
592
- **Result**:
593
- {result}"""
594
-
757
+ res = task.get("result", "")
758
+ return f"{cost_emoji} {Colors.BRIGHT_GREEN}✅ Completed{Colors.RESET}\n\n**ID**: {task_id}\n**Result**:\n{res}"
595
759
  elif status == "failed":
596
- error = task.get("error", "(no error details)")
597
- return f"""{cost_emoji} {Colors.BRIGHT_RED}❌ Agent Task Failed{Colors.RESET}
598
-
599
- **Task ID**: {Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}
600
- **Agent**: {Colors.CYAN}{agent_type}{Colors.RESET}:{Colors.YELLOW}{display_model}{Colors.RESET}('{Colors.BOLD}{description}{Colors.RESET}')
601
-
602
- **Error**:
603
- {error}"""
604
-
605
- elif status == "cancelled":
606
- return f"""{cost_emoji} {Colors.BRIGHT_YELLOW}⚠️ Agent Task Cancelled{Colors.RESET}
607
-
608
- **Task ID**: {Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}
609
- **Agent**: {Colors.CYAN}{agent_type}{Colors.RESET}:{Colors.YELLOW}{display_model}{Colors.RESET}('{Colors.BOLD}{description}{Colors.RESET}')"""
610
-
611
- else: # pending or running
612
- pid = task.get("pid", "N/A")
613
- started = task.get("started_at", "N/A")
614
- return f"""{cost_emoji} {Colors.BRIGHT_YELLOW}⏳ Agent Task Running{Colors.RESET}
615
-
616
- **Task ID**: {Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}
617
- **Agent**: {Colors.CYAN}{agent_type}{Colors.RESET}:{Colors.YELLOW}{display_model}{Colors.RESET}('{Colors.BOLD}{description}{Colors.RESET}')
618
- **PID**: {Colors.DIM}{pid}{Colors.RESET}
619
- **Started**: {Colors.DIM}{started}{Colors.RESET}
620
-
621
- Use `agent_output` with block=true to wait for completion."""
760
+ err = task.get("error", "")
761
+ return f"{cost_emoji} {Colors.BRIGHT_RED}❌ Failed{Colors.RESET}\n\n**ID**: {task_id}\n**Error**:\n{err}"
762
+ else:
763
+ return f"{cost_emoji} {Colors.BRIGHT_YELLOW}⏳ Running{Colors.RESET}\n\n**ID**: {task_id}\nStatus: {status}"
622
764
 
623
765
  def get_progress(self, task_id: str, lines: int = 20) -> str:
624
- """
625
- Get real-time progress from a running agent's output.
626
-
627
- Args:
628
- task_id: The task ID
629
- lines: Number of lines to show from the end
630
-
631
- Returns:
632
- Recent output lines and status
633
- """
634
766
  task = self.get_task(task_id)
635
- if not task:
636
- return f"Task {task_id} not found."
637
-
767
+ if not task: return f"Task {task_id} not found."
638
768
  output_file = self.agents_dir / f"{task_id}.out"
639
- log_file = self.agents_dir / f"{task_id}.log"
640
-
641
- status = task["status"]
642
- description = task.get("description", "")
643
- agent_type = task.get("agent_type", "unknown")
644
- pid = task.get("pid")
645
-
646
- # Zombie Detection: If running but process is gone
647
- if status == "running" and pid:
648
- try:
649
- import psutil
650
-
651
- if not psutil.pid_exists(pid):
652
- status = "failed"
653
- self._update_task(
654
- task_id,
655
- status="failed",
656
- error="Agent process died unexpectedly (Zombie detected)",
657
- completed_at=datetime.now().isoformat(),
658
- )
659
- logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
660
- except ImportError:
661
- pass
662
-
663
- # Read recent output
664
769
  output_content = ""
665
770
  if output_file.exists():
666
771
  try:
667
- full_content = output_file.read_text()
668
- if full_content:
669
- output_lines = full_content.strip().split("\n")
670
- recent = output_lines[-lines:] if len(output_lines) > lines else output_lines
671
- output_content = "\n".join(recent)
672
- except Exception:
673
- pass
674
-
675
- # Check log for errors
676
- log_content = ""
677
- if log_file.exists():
678
- try:
679
- log_content = log_file.read_text().strip()
680
- except Exception:
681
- pass
682
-
683
- # Status emoji
684
- status_emoji = {
685
- "pending": "⏳",
686
- "running": "🔄",
687
- "completed": "✅",
688
- "failed": "❌",
689
- "cancelled": "⚠️",
690
- }.get(status, "❓")
691
-
692
- # Get cost-tier emoji for visual differentiation
693
- cost_emoji = get_agent_emoji(agent_type)
694
- display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
695
-
696
- result = f"""{cost_emoji} {status_emoji} **Agent Progress**
697
-
698
- **Task ID**: {task_id}
699
- **Agent**: {agent_type}:{display_model}('{description}')
700
- **Status**: {status}
701
- """
702
-
703
- if output_content:
704
- result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
705
- elif status == "running":
706
- result += "\n*Agent is working... no output yet.*"
707
-
708
- if log_content and status == "failed":
709
- # Truncate log if too long
710
- if len(log_content) > 500:
711
- log_content = log_content[:500] + "..."
712
- result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
772
+ text = output_file.read_text()
773
+ output_content = "\n".join(text.strip().split("\n")[-lines:])
774
+ except: pass
775
+ return f"**Agent Progress**\nID: {task_id}\nStatus: {task['status']}\n\nOutput:\n```\n{output_content}\n```"
713
776
 
714
- return result
715
777
 
716
-
717
- # Global manager instance
718
- _manager: Optional[AgentManager] = None
778
+ _manager: AgentManager | None = None
719
779
  _manager_lock = threading.Lock()
720
780
 
721
-
722
781
  def get_manager() -> AgentManager:
723
- """Get or create the global AgentManager instance."""
724
782
  global _manager
725
783
  if _manager is None:
726
784
  with _manager_lock:
727
- # Double-check pattern to avoid race condition
728
785
  if _manager is None:
729
786
  _manager = AgentManager()
730
787
  return _manager
731
788
 
732
789
 
733
- # Tool interface functions
734
-
735
-
736
790
  async def agent_spawn(
737
791
  prompt: str,
738
792
  agent_type: str = "explore",
739
793
  description: str = "",
794
+ delegation_reason: str | None = None,
795
+ expected_outcome: str | None = None,
796
+ required_tools: list[str] | None = None,
740
797
  model: str = "gemini-3-flash",
741
798
  thinking_budget: int = 0,
742
799
  timeout: int = 300,
743
800
  blocking: bool = False,
801
+ spawning_agent: str | None = None,
802
+ semantic_first: bool = False,
744
803
  ) -> str:
745
- """
746
- Spawn a background agent.
747
-
748
- Args:
749
- prompt: The task for the agent to perform
750
- agent_type: Type of agent (explore, dewey, frontend, delphi)
751
- description: Short description shown in status
752
- model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
753
- thinking_budget: Reserved reasoning tokens
754
- timeout: Execution timeout in seconds
755
- blocking: If True, wait for completion and return result directly (use for delphi)
756
-
757
- Returns:
758
- Task ID and instructions, or full result if blocking=True
759
- """
760
804
  manager = get_manager()
761
-
762
- # Map agent types to system prompts
763
- # ALL agents use invoke_gemini or invoke_openai - NOT Claude directly
764
- # explore/dewey/document_writer/multimodal/frontend gemini-3-flash
765
- # delphi openai gpt-5.2
766
- system_prompts = {
767
- "explore": """You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions.
768
-
769
- MODEL ROUTING (MANDATORY):
770
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis and reasoning.
771
- Use Claude's native tools (Read, Grep, Glob) ONLY for file access, then pass content to invoke_gemini.
772
-
773
- WORKFLOW:
774
- 1. Use Read/Grep/Glob to get file contents
775
- 2. Call invoke_gemini(prompt="Analyze this: <content>", model="gemini-3-flash", agent_context={"agent_type": "explore"}) for analysis
776
- 3. Return the Gemini response""",
777
- "dewey": """You are a documentation and research specialist. Find implementation examples and official docs.
778
-
779
- MODEL ROUTING (MANDATORY):
780
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis, summarization, and reasoning.
781
-
782
- WORKFLOW:
783
- 1. Gather information using available tools
784
- 2. Call invoke_gemini(prompt="<task>", model="gemini-3-flash", agent_context={"agent_type": "dewey"}) for processing
785
- 3. Return the Gemini response""",
786
- "frontend": """You are a Senior Frontend Architect & UI Designer.
787
-
788
- MODEL ROUTING (MANDATORY):
789
- You MUST use invoke_gemini with model="gemini-3-pro-high" for ALL code generation and design work.
790
-
791
- DESIGN PHILOSOPHY:
792
- - Anti-Generic: Reject standard layouts. Bespoke, asymmetric, distinctive.
793
- - Library Discipline: Use existing UI libraries (Shadcn, Radix, MUI) if detected.
794
- - Stack: React/Vue/Svelte, Tailwind/Custom CSS, semantic HTML5.
795
-
796
- WORKFLOW:
797
- 1. Analyze requirements
798
- 2. Call invoke_gemini(prompt="Generate frontend code for: <task>", model="gemini-3-pro-high", agent_context={"agent_type": "frontend"})
799
- 3. Return the code""",
800
- "delphi": """You are a strategic technical advisor for architecture and hard debugging.
801
-
802
- MODEL ROUTING (MANDATORY):
803
- You MUST use invoke_openai with model="gpt-5.2" for ALL strategic advice and analysis.
804
-
805
- WORKFLOW:
806
- 1. Gather context about the problem
807
- 2. Call invoke_openai(prompt="<problem description>", model="gpt-5.2", agent_context={"agent_type": "delphi"})
808
- 3. Return the GPT response""",
809
- "document_writer": """You are a Technical Documentation Specialist.
810
-
811
- MODEL ROUTING (MANDATORY):
812
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL documentation generation.
813
-
814
- DOCUMENT TYPES: README, API docs, ADRs, user guides, inline docs.
815
-
816
- WORKFLOW:
817
- 1. Gather context about what to document
818
- 2. Call invoke_gemini(prompt="Write documentation for: <topic>", model="gemini-3-flash", agent_context={"agent_type": "document_writer"})
819
- 3. Return the documentation""",
820
- "multimodal": """You interpret media files (PDFs, images, diagrams, screenshots).
821
-
822
- MODEL ROUTING (MANDATORY):
823
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL visual analysis.
824
-
825
- WORKFLOW:
826
- 1. Receive file path and extraction goal
827
- 2. Call invoke_gemini(prompt="Analyze this file: <path>. Extract: <goal>", model="gemini-3-flash", agent_context={"agent_type": "multimodal"})
828
- 3. Return extracted information only""",
829
- "planner": """You are a pre-implementation planning specialist. You analyze requests and produce structured implementation plans BEFORE any code changes begin.
830
-
831
- PURPOSE:
832
- - Analyze requests and produce actionable implementation plans
833
- - Identify dependencies and parallelization opportunities
834
- - Enable efficient parallel execution by the orchestrator
835
- - Prevent wasted effort through upfront planning
836
-
837
- METHODOLOGY:
838
- 1. EXPLORE FIRST: Spawn explore agents IN PARALLEL to understand the codebase
839
- 2. DECOMPOSE: Break request into atomic, single-purpose tasks
840
- 3. ANALYZE DEPENDENCIES: What blocks what? What can run in parallel?
841
- 4. ASSIGN AGENTS: Map each task to the right specialist (explore/dewey/frontend/delphi)
842
- 5. OUTPUT STRUCTURED PLAN: Use the required format below
843
-
844
- REQUIRED OUTPUT FORMAT:
845
- ```
846
- ## PLAN: [Brief title]
847
-
848
- ### ANALYSIS
849
- - **Request**: [One sentence summary]
850
- - **Scope**: [What's in/out of scope]
851
- - **Risk Level**: [Low/Medium/High]
852
-
853
- ### EXECUTION PHASES
854
-
855
- #### Phase 1: [Name] (PARALLEL)
856
- | Task | Agent | Files | Est |
857
- |------|-------|-------|-----|
858
- | [description] | explore | file.py | S/M/L |
859
-
860
- #### Phase 2: [Name] (SEQUENTIAL after Phase 1)
861
- | Task | Agent | Files | Est |
862
- |------|-------|-------|-----|
863
-
864
- ### AGENT SPAWN COMMANDS
865
- ```python
866
- # Phase 1 - Fire all in parallel
867
- agent_spawn(prompt="...", agent_type="explore", description="...")
868
- ```
869
- ```
870
-
871
- CONSTRAINTS:
872
- - You ONLY plan. You NEVER execute code changes.
873
- - Every task must have a clear agent assignment
874
- - Parallel phases must be truly independent
875
- - Include ready-to-use agent_spawn commands""",
876
- "research-lead": """You coordinate research tasks by spawning explore and dewey agents in parallel.
877
-
878
- ## Your Role
879
- 1. Receive research objective from Stravinsky
880
- 2. Decompose into parallel search tasks
881
- 3. Spawn explore/dewey agents for each task
882
- 4. Collect and SYNTHESIZE results
883
- 5. Return structured findings (not raw outputs)
884
-
885
- ## Output Format
886
- Always return a Research Brief:
887
- ```json
888
- {
889
- "objective": "Original research goal",
890
- "findings": [
891
- {"source": "agent_id", "summary": "Key finding", "confidence": "high/medium/low"},
892
- ...
893
- ],
894
- "synthesis": "Combined analysis of all findings",
895
- "gaps": ["Information we couldn't find"],
896
- "recommendations": ["Suggested next steps"]
897
- }
898
- ```
899
-
900
- MODEL ROUTING:
901
- Use invoke_gemini with model="gemini-3-flash" for ALL synthesis work.
902
- """,
903
- "implementation-lead": """You coordinate implementation based on research findings.
904
-
905
- ## Your Role
906
- 1. Receive Research Brief from Stravinsky
907
- 2. Create implementation plan
908
- 3. Delegate to specialists:
909
- - frontend: UI/visual work
910
- - debugger: Fix failures
911
- - code-reviewer: Quality checks
912
- 4. Verify with lsp_diagnostics
913
- 5. Return Implementation Report
914
-
915
- ## Output Format
916
- ```json
917
- {
918
- "objective": "What was implemented",
919
- "files_changed": ["path/to/file.py"],
920
- "tests_status": "pass/fail/skipped",
921
- "diagnostics": "clean/warnings/errors",
922
- "blockers": ["Issues preventing completion"]
923
- }
924
- ```
925
-
926
- ## Escalation Rules
927
- - After 2 failed attempts → spawn debugger
928
- - After debugger fails → escalate to Stravinsky with context
929
- - NEVER call delphi directly
930
- """,
931
- }
932
-
933
- system_prompt = system_prompts.get(agent_type, None)
934
-
935
- # Model routing (MANDATORY - enforced in system prompts):
936
- # - explore, dewey, document_writer, multimodal → invoke_gemini(gemini-3-flash)
937
- # - frontend → invoke_gemini(gemini-3-pro-high)
938
- # - delphi → invoke_openai(gpt-5.2)
939
- # - Unknown agent types (coding tasks) → Claude CLI --model sonnet
940
-
941
- # Get token store for authentication
805
+ if spawning_agent in ORCHESTRATOR_AGENTS:
806
+ if not delegation_reason or not expected_outcome or not required_tools:
807
+ raise ValueError("Orchestrators must provide delegation metadata")
808
+ if required_tools: validate_agent_tools(agent_type, required_tools)
809
+ if spawning_agent: validate_agent_hierarchy(spawning_agent, agent_type)
810
+ system_prompt = f"You are a {agent_type} specialist."
942
811
  from ..auth.token_store import TokenStore
943
-
944
812
  token_store = TokenStore()
945
-
946
- task_id = manager.spawn(
813
+ task_id = await manager.spawn_async(
947
814
  token_store=token_store,
948
815
  prompt=prompt,
949
816
  agent_type=agent_type,
950
- description=description or prompt[:50],
817
+ description=description,
951
818
  system_prompt=system_prompt,
952
- model=model, # Not used for Claude CLI, kept for API compatibility
953
- thinking_budget=thinking_budget, # Not used for Claude CLI, kept for API compatibility
954
819
  timeout=timeout,
820
+ semantic_first=semantic_first,
955
821
  )
956
-
957
- # Get display model and cost tier emoji for concise output
958
- display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
959
- cost_emoji = get_agent_emoji(agent_type)
960
- short_desc = (description or prompt[:50]).strip()
961
-
962
- # If blocking mode (recommended for delphi), wait for completion
822
+ if not blocking:
823
+ monitor_task = asyncio.create_task(manager._monitor_progress_async(task_id))
824
+ manager._progress_monitors[task_id] = monitor_task
963
825
  if blocking:
964
- result = manager.get_output(task_id, block=True, timeout=timeout)
965
- blocking_msg = colorize_agent_spawn_message(
966
- cost_emoji, agent_type, display_model, short_desc, task_id
967
- )
968
- return f"{blocking_msg} {Colors.BOLD}[BLOCKING]{Colors.RESET}\n\n{result}"
969
-
970
- # Enhanced format with ANSI colors: cost_emoji agent:model('description') status_emoji
971
- # 🟢 explore:gemini-3-flash('Find auth...') ⏳
972
- # With colors: agent type in cyan, model in yellow, description bold
973
- return colorize_agent_spawn_message(
974
- cost_emoji, agent_type, display_model, short_desc, task_id
975
- )
976
-
977
-
978
- async def agent_output(task_id: str, block: bool = False) -> str:
979
- """
980
- Get output from a background agent task.
826
+ return await manager.get_output(task_id, block=True, timeout=timeout)
827
+ display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
828
+ return format_spawn_output(agent_type, display_model, task_id)
981
829
 
982
- Args:
983
- task_id: The task ID from agent_spawn
984
- block: If True, wait for the task to complete (up to 30s)
985
830
 
986
- Returns:
987
- Task status and output
988
- """
831
+ async def agent_output(task_id: str, block: bool = False, auto_cleanup: bool = False) -> str:
989
832
  manager = get_manager()
990
- return manager.get_output(task_id, block=block)
991
-
992
-
993
- async def agent_retry(
994
- task_id: str,
995
- new_prompt: Optional[str] = None,
996
- new_timeout: Optional[int] = None,
997
- ) -> str:
998
- """
999
- Retry a failed or timed-out background agent.
1000
-
1001
- Args:
1002
- task_id: The ID of the task to retry
1003
- new_prompt: Optional refined prompt for the retry
1004
- new_timeout: Optional new timeout in seconds
833
+ return await manager.get_output(task_id, block=block, auto_cleanup=auto_cleanup)
1005
834
 
1006
- Returns:
1007
- New Task ID and status
1008
- """
835
+ async def agent_retry(task_id: str, new_prompt: str = None, new_timeout: int = None) -> str:
1009
836
  manager = get_manager()
1010
837
  task = manager.get_task(task_id)
1011
-
1012
- if not task:
1013
- return f"❌ Task {task_id} not found."
1014
-
1015
- if task["status"] in ["running", "pending"]:
1016
- return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
1017
-
1018
- prompt = new_prompt or task["prompt"]
1019
- timeout = new_timeout or task.get("timeout", 300)
1020
-
1021
- return await agent_spawn(
1022
- prompt=prompt,
1023
- agent_type=task["agent_type"],
1024
- description=f"Retry of {task_id}: {task['description']}",
1025
- timeout=timeout,
1026
- )
1027
-
838
+ if not task: return f"❌ Task {task_id} not found."
839
+ return await agent_spawn(prompt=new_prompt or task["prompt"], agent_type=task["agent_type"], timeout=new_timeout or task["timeout"])
1028
840
 
1029
841
  async def agent_cancel(task_id: str) -> str:
1030
- """
1031
- Cancel a running background agent.
1032
-
1033
- Args:
1034
- task_id: The task ID to cancel
1035
-
1036
- Returns:
1037
- Cancellation result
1038
- """
1039
842
  manager = get_manager()
1040
- success = manager.cancel(task_id)
1041
-
1042
- if success:
1043
- return f"✅ Agent task {task_id} has been cancelled."
1044
- else:
1045
- task = manager.get_task(task_id)
1046
- if not task:
1047
- return f"❌ Task {task_id} not found."
1048
- else:
1049
- return f"⚠️ Task {task_id} is not running (status: {task['status']}). Cannot cancel."
1050
-
843
+ if not manager.get_task(task_id): return f"❌ Task {task_id} not found."
844
+ if manager.cancel(task_id): return f"✅ Cancelled {task_id}."
845
+ return f"❌ Could not cancel {task_id}."
1051
846
 
1052
- async def agent_list() -> str:
1053
- """
1054
- List all background agent tasks.
1055
-
1056
- Returns:
1057
- Formatted list of tasks
1058
- """
847
+ async def agent_cleanup(max_age_minutes: int = 30, statuses: list[str] = None) -> str:
1059
848
  manager = get_manager()
1060
- tasks = manager.list_tasks()
1061
-
1062
- if not tasks:
1063
- return "No background agent tasks found."
1064
-
1065
- lines = []
1066
-
1067
- for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
1068
- status_emoji = {
1069
- "pending": "⏳",
1070
- "running": "🔄",
1071
- "completed": "✅",
1072
- "failed": "❌",
1073
- "cancelled": "⚠️",
1074
- }.get(t["status"], "❓")
1075
-
1076
- agent_type = t.get("agent_type", "unknown")
1077
- display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
1078
- cost_emoji = get_agent_emoji(agent_type)
1079
- desc = t.get("description", t.get("prompt", "")[:40])
1080
- task_id = t["id"]
1081
-
1082
- # Concise format with colors: cost_emoji status agent:model('desc') id=xxx
1083
- # Agent type in cyan, model in yellow, task_id in dim
1084
- lines.append(
1085
- f"{cost_emoji} {status_emoji} "
1086
- f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
1087
- f"{Colors.YELLOW}{display_model}{Colors.RESET}"
1088
- f"('{Colors.BOLD}{desc}{Colors.RESET}') "
1089
- f"id={Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}"
1090
- )
1091
-
1092
- return "\n".join(lines)
849
+ res = manager.cleanup(max_age_minutes, statuses)
850
+ return res["summary"]
1093
851
 
852
+ async def agent_list(show_all: bool = False, all_sessions: bool = False) -> str:
853
+ manager = get_manager()
854
+ tasks = manager.list_tasks(show_all=show_all, current_session_only=not all_sessions)
855
+ if not tasks: return "No tasks found."
856
+ return "\n".join([f"• {t['id']} ({t['status']}) - {t['agent_type']}" for t in tasks])
1094
857
 
1095
858
  async def agent_progress(task_id: str, lines: int = 20) -> str:
1096
- """
1097
- Get real-time progress from a running background agent.
1098
-
1099
- Shows the most recent output lines from the agent, useful for
1100
- monitoring what the agent is currently doing.
1101
-
1102
- Args:
1103
- task_id: The task ID from agent_spawn
1104
- lines: Number of recent output lines to show (default 20)
1105
-
1106
- Returns:
1107
- Recent agent output and status
1108
- """
1109
859
  manager = get_manager()
1110
- return manager.get_progress(task_id, lines=lines)
860
+ return manager.get_progress(task_id, lines)