stravinsky 0.2.67__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 (190) 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 +112 -11
  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/MANIFEST_SCHEMA.md +305 -0
  11. mcp_bridge/config/README.md +276 -0
  12. mcp_bridge/config/__init__.py +2 -2
  13. mcp_bridge/config/hook_config.py +247 -0
  14. mcp_bridge/config/hooks_manifest.json +138 -0
  15. mcp_bridge/config/rate_limits.py +317 -0
  16. mcp_bridge/config/skills_manifest.json +128 -0
  17. mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
  18. mcp_bridge/hooks/__init__.py +19 -4
  19. mcp_bridge/hooks/agent_reminder.py +4 -4
  20. mcp_bridge/hooks/auto_slash_command.py +5 -5
  21. mcp_bridge/hooks/budget_optimizer.py +2 -2
  22. mcp_bridge/hooks/claude_limits_hook.py +114 -0
  23. mcp_bridge/hooks/comment_checker.py +3 -4
  24. mcp_bridge/hooks/compaction.py +2 -2
  25. mcp_bridge/hooks/context.py +2 -1
  26. mcp_bridge/hooks/context_monitor.py +2 -2
  27. mcp_bridge/hooks/delegation_policy.py +85 -0
  28. mcp_bridge/hooks/directory_context.py +3 -3
  29. mcp_bridge/hooks/edit_recovery.py +3 -2
  30. mcp_bridge/hooks/edit_recovery_policy.py +49 -0
  31. mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
  32. mcp_bridge/hooks/events.py +160 -0
  33. mcp_bridge/hooks/git_noninteractive.py +4 -4
  34. mcp_bridge/hooks/keyword_detector.py +8 -10
  35. mcp_bridge/hooks/manager.py +43 -22
  36. mcp_bridge/hooks/notification_hook.py +13 -6
  37. mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
  38. mcp_bridge/hooks/parallel_enforcer.py +5 -5
  39. mcp_bridge/hooks/parallel_execution.py +22 -10
  40. mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
  41. mcp_bridge/hooks/pre_compact.py +8 -9
  42. mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
  43. mcp_bridge/hooks/preemptive_compaction.py +2 -3
  44. mcp_bridge/hooks/routing_notifications.py +80 -0
  45. mcp_bridge/hooks/rules_injector.py +11 -19
  46. mcp_bridge/hooks/session_idle.py +4 -4
  47. mcp_bridge/hooks/session_notifier.py +4 -4
  48. mcp_bridge/hooks/session_recovery.py +4 -5
  49. mcp_bridge/hooks/stravinsky_mode.py +1 -1
  50. mcp_bridge/hooks/subagent_stop.py +1 -3
  51. mcp_bridge/hooks/task_validator.py +2 -2
  52. mcp_bridge/hooks/tmux_manager.py +7 -8
  53. mcp_bridge/hooks/todo_delegation.py +4 -1
  54. mcp_bridge/hooks/todo_enforcer.py +180 -10
  55. mcp_bridge/hooks/tool_messaging.py +113 -10
  56. mcp_bridge/hooks/truncation_policy.py +37 -0
  57. mcp_bridge/hooks/truncator.py +1 -2
  58. mcp_bridge/metrics/cost_tracker.py +115 -0
  59. mcp_bridge/native_search.py +93 -0
  60. mcp_bridge/native_watcher.py +118 -0
  61. mcp_bridge/notifications.py +150 -0
  62. mcp_bridge/orchestrator/enums.py +11 -0
  63. mcp_bridge/orchestrator/router.py +165 -0
  64. mcp_bridge/orchestrator/state.py +32 -0
  65. mcp_bridge/orchestrator/visualization.py +14 -0
  66. mcp_bridge/orchestrator/wisdom.py +34 -0
  67. mcp_bridge/prompts/__init__.py +1 -8
  68. mcp_bridge/prompts/dewey.py +1 -1
  69. mcp_bridge/prompts/planner.py +2 -4
  70. mcp_bridge/prompts/stravinsky.py +53 -31
  71. mcp_bridge/proxy/__init__.py +0 -0
  72. mcp_bridge/proxy/client.py +70 -0
  73. mcp_bridge/proxy/model_server.py +157 -0
  74. mcp_bridge/routing/__init__.py +43 -0
  75. mcp_bridge/routing/config.py +250 -0
  76. mcp_bridge/routing/model_tiers.py +135 -0
  77. mcp_bridge/routing/provider_state.py +261 -0
  78. mcp_bridge/routing/task_classifier.py +190 -0
  79. mcp_bridge/server.py +542 -59
  80. mcp_bridge/server_tools.py +738 -6
  81. mcp_bridge/tools/__init__.py +40 -25
  82. mcp_bridge/tools/agent_manager.py +616 -697
  83. mcp_bridge/tools/background_tasks.py +13 -17
  84. mcp_bridge/tools/code_search.py +70 -53
  85. mcp_bridge/tools/continuous_loop.py +0 -1
  86. mcp_bridge/tools/dashboard.py +19 -0
  87. mcp_bridge/tools/find_code.py +296 -0
  88. mcp_bridge/tools/init.py +1 -0
  89. mcp_bridge/tools/list_directory.py +42 -0
  90. mcp_bridge/tools/lsp/__init__.py +12 -5
  91. mcp_bridge/tools/lsp/manager.py +471 -0
  92. mcp_bridge/tools/lsp/tools.py +723 -207
  93. mcp_bridge/tools/model_invoke.py +1195 -273
  94. mcp_bridge/tools/mux_client.py +75 -0
  95. mcp_bridge/tools/project_context.py +1 -2
  96. mcp_bridge/tools/query_classifier.py +406 -0
  97. mcp_bridge/tools/read_file.py +84 -0
  98. mcp_bridge/tools/replace.py +45 -0
  99. mcp_bridge/tools/run_shell_command.py +38 -0
  100. mcp_bridge/tools/search_enhancements.py +347 -0
  101. mcp_bridge/tools/semantic_search.py +3627 -0
  102. mcp_bridge/tools/session_manager.py +0 -2
  103. mcp_bridge/tools/skill_loader.py +0 -1
  104. mcp_bridge/tools/task_runner.py +5 -7
  105. mcp_bridge/tools/templates.py +3 -3
  106. mcp_bridge/tools/tool_search.py +331 -0
  107. mcp_bridge/tools/write_file.py +29 -0
  108. mcp_bridge/update_manager.py +585 -0
  109. mcp_bridge/update_manager_pypi.py +297 -0
  110. mcp_bridge/utils/cache.py +82 -0
  111. mcp_bridge/utils/process.py +71 -0
  112. mcp_bridge/utils/session_state.py +51 -0
  113. mcp_bridge/utils/truncation.py +76 -0
  114. stravinsky-0.4.66.dist-info/METADATA +517 -0
  115. stravinsky-0.4.66.dist-info/RECORD +198 -0
  116. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
  117. stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
  118. stravinsky_claude_assets/agents/HOOKS.md +437 -0
  119. stravinsky_claude_assets/agents/code-reviewer.md +210 -0
  120. stravinsky_claude_assets/agents/comment_checker.md +580 -0
  121. stravinsky_claude_assets/agents/debugger.md +254 -0
  122. stravinsky_claude_assets/agents/delphi.md +495 -0
  123. stravinsky_claude_assets/agents/dewey.md +248 -0
  124. stravinsky_claude_assets/agents/explore.md +1198 -0
  125. stravinsky_claude_assets/agents/frontend.md +472 -0
  126. stravinsky_claude_assets/agents/implementation-lead.md +164 -0
  127. stravinsky_claude_assets/agents/momus.md +464 -0
  128. stravinsky_claude_assets/agents/research-lead.md +141 -0
  129. stravinsky_claude_assets/agents/stravinsky.md +730 -0
  130. stravinsky_claude_assets/commands/delphi.md +9 -0
  131. stravinsky_claude_assets/commands/dewey.md +54 -0
  132. stravinsky_claude_assets/commands/git-master.md +112 -0
  133. stravinsky_claude_assets/commands/index.md +49 -0
  134. stravinsky_claude_assets/commands/publish.md +86 -0
  135. stravinsky_claude_assets/commands/review.md +73 -0
  136. stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
  137. stravinsky_claude_assets/commands/str/agent_list.md +56 -0
  138. stravinsky_claude_assets/commands/str/agent_output.md +92 -0
  139. stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
  140. stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
  141. stravinsky_claude_assets/commands/str/cancel.md +51 -0
  142. stravinsky_claude_assets/commands/str/clean.md +97 -0
  143. stravinsky_claude_assets/commands/str/continue.md +38 -0
  144. stravinsky_claude_assets/commands/str/index.md +199 -0
  145. stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
  146. stravinsky_claude_assets/commands/str/search.md +205 -0
  147. stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
  148. stravinsky_claude_assets/commands/str/stats.md +71 -0
  149. stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
  150. stravinsky_claude_assets/commands/str/unwatch.md +42 -0
  151. stravinsky_claude_assets/commands/str/watch.md +45 -0
  152. stravinsky_claude_assets/commands/strav.md +53 -0
  153. stravinsky_claude_assets/commands/stravinsky.md +292 -0
  154. stravinsky_claude_assets/commands/verify.md +60 -0
  155. stravinsky_claude_assets/commands/version.md +5 -0
  156. stravinsky_claude_assets/hooks/README.md +248 -0
  157. stravinsky_claude_assets/hooks/comment_checker.py +193 -0
  158. stravinsky_claude_assets/hooks/context.py +38 -0
  159. stravinsky_claude_assets/hooks/context_monitor.py +153 -0
  160. stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
  161. stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
  162. stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
  163. stravinsky_claude_assets/hooks/notification_hook.py +103 -0
  164. stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
  165. stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
  166. stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
  167. stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
  168. stravinsky_claude_assets/hooks/pre_compact.py +123 -0
  169. stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
  170. stravinsky_claude_assets/hooks/session_recovery.py +263 -0
  171. stravinsky_claude_assets/hooks/stop_hook.py +89 -0
  172. stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
  173. stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
  174. stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
  175. stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
  176. stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
  177. stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
  178. stravinsky_claude_assets/hooks/truncator.py +23 -0
  179. stravinsky_claude_assets/rules/deployment_safety.md +51 -0
  180. stravinsky_claude_assets/rules/integration_wiring.md +89 -0
  181. stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
  182. stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
  183. stravinsky_claude_assets/settings.json +152 -0
  184. stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
  185. stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
  186. stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
  187. stravinsky_claude_assets/task_dependencies.json +34 -0
  188. stravinsky-0.2.67.dist-info/METADATA +0 -284
  189. stravinsky-0.2.67.dist-info/RECORD +0 -76
  190. {stravinsky-0.2.67.dist-info → stravinsky-0.4.66.dist-info}/WHEEL +0 -0
@@ -5,123 +5,303 @@ 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
- # 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,
40
56
  "planner": "opus",
41
- # Default for unknown agent types (coding tasks) - use Sonnet 4.5
42
57
  "_default": "sonnet",
43
58
  }
44
59
 
45
- # Cost tier classification (from oh-my-opencode pattern)
46
60
  AGENT_COST_TIERS = {
47
- "explore": "CHEAP", # Uses gemini-3-flash
48
- "dewey": "CHEAP", # Uses gemini-3-flash
49
- "document_writer": "CHEAP", # Uses gemini-3-flash
50
- "multimodal": "CHEAP", # Uses gemini-3-flash
51
- "frontend": "MEDIUM", # Uses gemini-3-pro-high
52
- "delphi": "EXPENSIVE", # Uses gpt-5.2 (OpenAI GPT)
53
- "planner": "EXPENSIVE", # Uses Claude Opus 4.5
54
- "_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",
55
75
  }
56
76
 
57
- # Display model names for output formatting (user-visible)
58
77
  AGENT_DISPLAY_MODELS = {
59
78
  "explore": "gemini-3-flash",
60
79
  "dewey": "gemini-3-flash",
61
80
  "document_writer": "gemini-3-flash",
62
81
  "multimodal": "gemini-3-flash",
82
+ "research-lead": "gemini-3-flash",
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",
63
88
  "frontend": "gemini-3-pro-high",
64
89
  "delphi": "gpt-5.2",
65
90
  "planner": "opus-4.5",
66
91
  "_default": "sonnet-4.5",
67
92
  }
68
93
 
94
+ COST_TIER_EMOJI = {
95
+ "CHEAP": "🟢",
96
+ "MEDIUM": "🔵",
97
+ "EXPENSIVE": "🟣",
98
+ }
99
+
100
+ MODEL_FAMILY_EMOJI = {
101
+ "gemini-3-flash": "🟢",
102
+ "gemini-3-pro-high": "🔵",
103
+ "haiku": "🟢",
104
+ "sonnet-4.5": "🟠",
105
+ "opus-4.5": "🟣",
106
+ "gpt-5.2": "🟣",
107
+ }
108
+
109
+
110
+ class Colors:
111
+ """ANSI color codes for colorized terminal output."""
112
+
113
+ RESET = "\033[0m"
114
+ BOLD = "\033[1m"
115
+ DIM = "\033[2m"
116
+ BLACK = "\033[30m"
117
+ RED = "\033[31m"
118
+ GREEN = "\033[32m"
119
+ YELLOW = "\033[33m"
120
+ BLUE = "\033[34m"
121
+ MAGENTA = "\033[35m"
122
+ CYAN = "\033[36m"
123
+ WHITE = "\033[37m"
124
+ BRIGHT_BLACK = "\033[90m"
125
+ BRIGHT_RED = "\033[91m"
126
+ BRIGHT_GREEN = "\033[92m"
127
+ BRIGHT_YELLOW = "\033[93m"
128
+ BRIGHT_BLUE = "\033[94m"
129
+ BRIGHT_MAGENTA = "\033[95m"
130
+ BRIGHT_CYAN = "\033[96m"
131
+ BRIGHT_WHITE = "\033[97m"
132
+
133
+
134
+ def get_agent_emoji(agent_type: str) -> str:
135
+ """Get the colored emoji indicator for an agent based on its cost tier."""
136
+ cost_tier = AGENT_COST_TIERS.get(agent_type, AGENT_COST_TIERS["_default"])
137
+ return COST_TIER_EMOJI.get(cost_tier, "⚪")
138
+
139
+
140
+ def get_model_emoji(model_name: str) -> str:
141
+ """Get the colored emoji indicator for a model."""
142
+ return MODEL_FAMILY_EMOJI.get(model_name, "⚪")
143
+
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
+
229
+ def colorize_agent_spawn_message(
230
+ cost_emoji: str,
231
+ agent_type: str,
232
+ display_model: str,
233
+ description: str,
234
+ task_id: str,
235
+ ) -> str:
236
+ short_desc = (description or "")[:50].strip()
237
+ colored_message = (
238
+ f"{cost_emoji} "
239
+ f"{Colors.CYAN}{agent_type}{Colors.RESET}:"
240
+ f"{Colors.YELLOW}{display_model}{Colors.RESET}"
241
+ f"('{Colors.BOLD}{short_desc}{Colors.RESET}') "
242
+ f"{Colors.BRIGHT_GREEN}⏳{Colors.RESET}\n"
243
+ f"task_id={Colors.BRIGHT_BLACK}{task_id}{Colors.RESET}"
244
+ )
245
+ return colored_message
246
+
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
+
69
267
 
70
268
  @dataclass
71
269
  class AgentTask:
72
- """Represents a background agent task with full tool access."""
73
-
74
270
  id: str
75
271
  prompt: str
76
- agent_type: str # explore, dewey, frontend, delphi, etc.
272
+ agent_type: str
77
273
  description: str
78
- status: str # pending, running, completed, failed, cancelled
274
+ status: str
79
275
  created_at: str
80
- parent_session_id: Optional[str] = None
81
- started_at: Optional[str] = None
82
- completed_at: Optional[str] = None
83
- result: Optional[str] = None
84
- error: Optional[str] = None
85
- pid: Optional[int] = None
86
- timeout: int = 300 # Default 5 minutes
87
- progress: Optional[Dict[str, Any]] = None # tool calls, last update
88
-
89
-
90
- @dataclass
91
- class AgentProgress:
92
- """Progress tracking for a running agent."""
93
-
94
- tool_calls: int = 0
95
- last_tool: Optional[str] = None
96
- last_message: Optional[str] = None
97
- 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
98
285
 
99
286
 
100
287
  class AgentManager:
101
- """
102
- Manages background agent execution using Claude Code CLI.
103
-
104
- Key features:
105
- - Spawns agents with full tool access via `claude -p`
106
- - Tracks task status and progress
107
- - Persists state to .stravinsky/agents.json
108
- - Provides notification mechanism for task completion
109
- """
110
-
111
- # Dynamic CLI path - find claude in PATH, fallback to common locations
112
288
  CLAUDE_CLI = shutil.which("claude") or "/opt/homebrew/bin/claude"
113
289
 
114
- def __init__(self, base_dir: Optional[str] = None):
115
- # Initialize lock FIRST - used by _save_tasks and _load_tasks
290
+ def __init__(self, base_dir: str | None = None):
116
291
  self._lock = threading.RLock()
292
+ import uuid as uuid_module
117
293
 
294
+ self.session_id = os.environ.get(
295
+ "CLAUDE_CODE_SESSION_ID", f"pid_{os.getpid()}_{uuid_module.uuid4().hex[:8]}"
296
+ )
297
+
118
298
  if base_dir:
119
299
  self.base_dir = Path(base_dir)
120
300
  else:
121
301
  self.base_dir = Path.cwd() / ".stravinsky"
122
302
 
123
303
  self.agents_dir = self.base_dir / "agents"
124
- self.state_file = self.base_dir / "agents.json"
304
+ self.state_file = self.base_dir / f"agents_{self.session_id}.json"
125
305
 
126
306
  self.base_dir.mkdir(parents=True, exist_ok=True)
127
307
  self.agents_dir.mkdir(parents=True, exist_ok=True)
@@ -129,79 +309,148 @@ class AgentManager:
129
309
  if not self.state_file.exists():
130
310
  self._save_tasks({})
131
311
 
132
- # In-memory tracking for running processes
133
- self._processes: Dict[str, subprocess.Popen] = {}
134
- 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)
135
365
 
136
- def _load_tasks(self) -> Dict[str, Any]:
137
- """Load tasks from persistent storage."""
366
+ def _load_tasks(self) -> dict[str, Any]:
138
367
  with self._lock:
139
368
  try:
140
369
  if not self.state_file.exists():
141
370
  return {}
142
- with open(self.state_file, "r") as f:
371
+ with open(self.state_file) as f:
143
372
  return json.load(f)
144
373
  except (json.JSONDecodeError, FileNotFoundError):
145
374
  return {}
146
375
 
147
- def _save_tasks(self, tasks: Dict[str, Any]):
148
- """Save tasks to persistent storage."""
149
- with self._lock:
150
- with open(self.state_file, "w") as f:
151
- 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)
152
379
 
153
380
  def _update_task(self, task_id: str, **kwargs):
154
- """Update a task's fields."""
155
381
  with self._lock:
156
382
  tasks = self._load_tasks()
157
383
  if task_id in tasks:
158
384
  tasks[task_id].update(kwargs)
159
385
  self._save_tasks(tasks)
160
386
 
161
- def get_task(self, task_id: str) -> Optional[Dict[str, Any]]:
162
- """Get a task by ID."""
387
+ def get_task(self, task_id: str) -> dict[str, Any] | None:
163
388
  tasks = self._load_tasks()
164
389
  return tasks.get(task_id)
165
390
 
166
- def list_tasks(self, parent_session_id: Optional[str] = None) -> List[Dict[str, Any]]:
167
- """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]]:
168
397
  tasks = self._load_tasks()
169
398
  task_list = list(tasks.values())
170
-
399
+ if current_session_only:
400
+ task_list = [t for t in task_list if t.get("terminal_session_id") == self.session_id]
171
401
  if parent_session_id:
172
402
  task_list = [t for t in task_list if t.get("parent_session_id") == parent_session_id]
173
-
403
+ if not show_all:
404
+ task_list = [t for t in task_list if t.get("status") in ["running", "pending"]]
174
405
  return task_list
175
406
 
176
- def spawn(
407
+ async def spawn_async(
177
408
  self,
178
409
  token_store: Any,
179
410
  prompt: str,
180
411
  agent_type: str = "explore",
181
412
  description: str = "",
182
- parent_session_id: Optional[str] = None,
183
- system_prompt: Optional[str] = None,
413
+ parent_session_id: str | None = None,
414
+ system_prompt: str | None = None,
184
415
  model: str = "gemini-3-flash",
185
416
  thinking_budget: int = 0,
186
417
  timeout: int = 300,
418
+ semantic_first: bool = False,
187
419
  ) -> str:
188
- """
189
- Spawn a new background agent.
190
-
191
- Args:
192
- prompt: The task prompt for the agent
193
- agent_type: Type of agent (explore, dewey, frontend, delphi)
194
- description: Short description for status display
195
- parent_session_id: Optional parent session for notifications
196
- system_prompt: Optional custom system prompt
197
- model: Model to use (gemini-3-flash, claude, etc.)
198
- timeout: Maximum execution time in seconds
199
-
200
- Returns:
201
- Task ID for tracking
202
- """
203
- 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}")
204
452
 
453
+ import uuid as uuid_module
205
454
  task_id = f"agent_{uuid_module.uuid4().hex[:8]}"
206
455
 
207
456
  task = AgentTask(
@@ -212,730 +461,400 @@ class AgentManager:
212
461
  status="pending",
213
462
  created_at=datetime.now().isoformat(),
214
463
  parent_session_id=parent_session_id,
464
+ terminal_session_id=self.session_id,
215
465
  timeout=timeout,
216
466
  )
217
467
 
218
- # Persist task
219
468
  with self._lock:
220
469
  tasks = self._load_tasks()
221
470
  tasks[task_id] = asdict(task)
222
471
  self._save_tasks(tasks)
223
472
 
224
- # Start background execution
225
- self._execute_agent(
226
- 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
+ )
227
477
  )
478
+ self._tasks[task_id] = task_obj
228
479
 
229
480
  return task_id
230
481
 
231
- 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(
232
497
  self,
233
498
  task_id: str,
234
499
  token_store: Any,
235
500
  prompt: str,
236
501
  agent_type: str,
237
- system_prompt: Optional[str] = None,
502
+ system_prompt: str | None = None,
238
503
  model: str = "gemini-3-flash",
239
504
  thinking_budget: int = 0,
240
505
  timeout: int = 300,
241
506
  ):
242
- """Execute agent using Claude CLI with full tool access.
243
-
244
- Uses `claude -p` to spawn a background agent with complete tool access,
245
- just like oh-my-opencode's Sisyphus implementation.
246
- """
247
-
248
- def run_agent():
249
- log_file = self.agents_dir / f"{task_id}.log"
250
- output_file = self.agents_dir / f"{task_id}.out"
251
-
252
- 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"
253
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
+
254
569
  try:
255
- # Prepare full prompt with system prompt if provided
256
- full_prompt = prompt
257
- if system_prompt:
258
- full_prompt = f"{system_prompt}\n\n---\n\n{prompt}"
259
-
260
- logger.info(f"[AgentManager] Spawning Claude CLI agent {task_id} ({agent_type})")
261
-
262
- # Build Claude CLI command with full tool access
263
- # Using `claude -p` for non-interactive mode with prompt
264
- cmd = [
265
- self.CLAUDE_CLI,
266
- "-p",
267
- full_prompt,
268
- "--output-format",
269
- "text",
270
- "--dangerously-skip-permissions", # Critical: bypass permission prompts
271
- ]
272
-
273
- # Model routing:
274
- # - Specialized agents (explore/dewey/etc): None = use CLI default, they call invoke_*
275
- # - Unknown agent types (coding tasks): Use Sonnet 4.5
276
- if agent_type in AGENT_MODEL_ROUTING:
277
- cli_model = AGENT_MODEL_ROUTING[agent_type] # None for specialized
278
- else:
279
- cli_model = AGENT_MODEL_ROUTING.get("_default", "sonnet")
280
-
281
- if cli_model:
282
- cmd.extend(["--model", cli_model])
283
- logger.info(f"[AgentManager] Using --model {cli_model} for {agent_type} agent")
284
-
285
- # Add system prompt file if we have one
286
- if system_prompt:
287
- system_file = self.agents_dir / f"{task_id}.system"
288
- system_file.write_text(system_prompt)
289
- cmd.extend(["--system-prompt", str(system_file)])
290
-
291
- # Execute Claude CLI as subprocess with full tool access
292
- logger.info(f"[AgentManager] Running: {' '.join(cmd[:3])}...")
293
-
294
- # Use PIPE for stderr to capture it properly
295
- # (Previously used file handle which was closed before process finished)
296
- process = subprocess.Popen(
297
- cmd,
298
- stdin=subprocess.DEVNULL, # Critical: prevent stdin blocking
299
- stdout=subprocess.PIPE,
300
- stderr=subprocess.PIPE,
301
- text=True,
302
- cwd=str(Path.cwd()),
303
- env={**os.environ, "CLAUDE_CODE_ENTRYPOINT": "stravinsky-agent"},
304
- 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
305
577
  )
306
-
307
- # Track the process
308
- self._processes[task_id] = process
309
- self._update_task(task_id, pid=process.pid)
310
-
311
- # Wait for completion with timeout
578
+ except asyncio.TimeoutError:
312
579
  try:
313
- stdout, stderr = process.communicate(timeout=timeout)
314
- result = stdout.strip() if stdout else ""
315
-
316
- # Write stderr to log file
317
- if stderr:
318
- log_file.write_text(stderr)
319
-
320
- if process.returncode == 0:
321
- output_file.write_text(result)
322
- self._update_task(
323
- task_id,
324
- status="completed",
325
- result=result,
326
- completed_at=datetime.now().isoformat(),
327
- )
328
- logger.info(f"[AgentManager] Agent {task_id} completed successfully")
329
- else:
330
- error_msg = f"Claude CLI exited with code {process.returncode}"
331
- if stderr:
332
- error_msg += f"\n{stderr}"
333
- self._update_task(
334
- task_id,
335
- status="failed",
336
- error=error_msg,
337
- completed_at=datetime.now().isoformat(),
338
- )
339
- logger.error(f"[AgentManager] Agent {task_id} failed: {error_msg}")
340
-
341
- except subprocess.TimeoutExpired:
342
- process.kill()
343
- self._update_task(
344
- task_id,
345
- status="failed",
346
- error=f"Agent timed out after {timeout}s",
347
- completed_at=datetime.now().isoformat(),
348
- )
349
- logger.warning(f"[AgentManager] Agent {task_id} timed out")
350
-
351
- except FileNotFoundError:
352
- error_msg = f"Claude CLI not found at {self.CLAUDE_CLI}. Install with: npm install -g @anthropic-ai/claude-code"
353
- 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)
354
597
  self._update_task(
355
598
  task_id,
356
- status="failed",
357
- error=error_msg,
599
+ status="completed",
600
+ result=stdout.strip(),
358
601
  completed_at=datetime.now().isoformat(),
359
602
  )
360
- logger.error(f"[AgentManager] {error_msg}")
361
-
362
- except Exception as e:
363
- error_msg = str(e)
364
- 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}")
365
606
  self._update_task(
366
607
  task_id,
367
608
  status="failed",
368
609
  error=error_msg,
369
610
  completed_at=datetime.now().isoformat(),
370
611
  )
371
- logger.exception(f"[AgentManager] Agent {task_id} exception")
372
612
 
373
- finally:
374
- self._processes.pop(task_id, None)
375
- self._notify_completion(task_id)
613
+ except asyncio.CancelledError:
614
+
615
+
376
616
 
377
- # Run in background thread
378
- thread = threading.Thread(target=run_agent, daemon=True)
379
- 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)
380
633
 
381
634
  def _notify_completion(self, task_id: str):
382
- """Queue notification for parent session."""
383
635
  task = self.get_task(task_id)
384
- if not task:
385
- return
386
-
387
- parent_id = task.get("parent_session_id")
388
- if parent_id:
636
+ if task and task.get("parent_session_id"):
637
+ parent_id = task["parent_session_id"]
389
638
  if parent_id not in self._notification_queue:
390
639
  self._notification_queue[parent_id] = []
391
-
392
640
  self._notification_queue[parent_id].append(task)
393
- logger.info(f"[AgentManager] Queued notification for {parent_id}: task {task_id}")
394
641
 
395
- def get_pending_notifications(self, session_id: str) -> List[Dict[str, Any]]:
396
- """Get and clear pending notifications for a session."""
397
- notifications = self._notification_queue.pop(session_id, [])
398
- 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
399
662
 
400
663
  def cancel(self, task_id: str) -> bool:
401
- """Cancel a running agent task."""
402
664
  task = self.get_task(task_id)
403
- if not task:
404
- return False
405
-
406
- if task["status"] != "running":
665
+ if not task or task["status"] not in ["pending", "running"]:
407
666
  return False
408
667
 
409
668
  process = self._processes.get(task_id)
410
669
  if process:
411
670
  try:
412
- os.killpg(os.getpgid(process.pid), signal.SIGTERM)
413
- process.wait(timeout=5)
414
- except Exception as e:
415
- logger.warning(f"[AgentManager] Failed to kill process for {task_id}: {e}")
416
- try:
417
- process.kill()
418
- except:
419
- pass
420
-
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
+
421
679
  self._update_task(task_id, status="cancelled", completed_at=datetime.now().isoformat())
422
-
423
680
  return True
424
681
 
425
- def stop_all(self, clear_history: bool = False) -> int:
426
- """
427
- Stop all running agents and optionally clear task history.
428
-
429
- Args:
430
- clear_history: If True, also remove completed/failed tasks from history
431
-
432
- Returns:
433
- Number of tasks stopped/cleared
434
- """
682
+ async def stop_all_async(self, clear_history: bool = False) -> int:
435
683
  tasks = self._load_tasks()
436
684
  stopped_count = 0
437
-
438
- # Stop running tasks
439
685
  for task_id, task in list(tasks.items()):
440
- if task.get("status") == "running":
441
- self.cancel(task_id)
442
- stopped_count += 1
443
-
444
- # 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
+
445
698
  if clear_history:
446
699
  cleared = len(tasks)
447
700
  self._save_tasks({})
448
701
  self._processes.clear()
449
- logger.info(f"[AgentManager] Cleared all {cleared} agent tasks")
702
+ self._tasks.clear()
703
+ self._progress_monitors.clear()
450
704
  return cleared
451
-
452
705
  return stopped_count
453
706
 
454
- def get_output(self, task_id: str, block: bool = False, timeout: float = 30.0) -> str:
455
- """
456
- Get output from an agent task.
457
-
458
- Args:
459
- task_id: The task ID
460
- block: If True, wait for completion
461
- timeout: Max seconds to wait if blocking
462
-
463
- Returns:
464
- Formatted task output/status
465
- """
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:
466
740
  task = self.get_task(task_id)
467
- if not task:
468
- return f"Task {task_id} not found."
741
+ if not task: return f"Task {task_id} not found."
469
742
 
470
- if block and task["status"] == "running":
471
- # Poll for completion
472
- start = datetime.now()
473
- 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:
474
746
  task = self.get_task(task_id)
475
- if not task or task["status"] != "running":
476
- break
477
- time.sleep(0.5)
478
-
479
- # Refresh task state after potential blocking wait
480
- if not task:
481
- 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)
482
749
 
750
+ task = self.get_task(task_id)
483
751
  status = task["status"]
484
- description = task.get("description", "")
485
752
  agent_type = task.get("agent_type", "unknown")
753
+ cost_emoji = get_agent_emoji(agent_type)
754
+ display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
486
755
 
487
756
  if status == "completed":
488
- result = task.get("result", "(no output)")
489
- return f"""Agent Task Completed
490
-
491
- **Task ID**: {task_id}
492
- **Agent**: {agent_type}
493
- **Description**: {description}
494
-
495
- **Result**:
496
- {result}"""
497
-
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}"
498
759
  elif status == "failed":
499
- error = task.get("error", "(no error details)")
500
- return f"""Agent Task Failed
501
-
502
- **Task ID**: {task_id}
503
- **Agent**: {agent_type}
504
- **Description**: {description}
505
-
506
- **Error**:
507
- {error}"""
508
-
509
- elif status == "cancelled":
510
- return f"""⚠️ Agent Task Cancelled
511
-
512
- **Task ID**: {task_id}
513
- **Agent**: {agent_type}
514
- **Description**: {description}"""
515
-
516
- else: # pending or running
517
- pid = task.get("pid", "N/A")
518
- started = task.get("started_at", "N/A")
519
- return f"""⏳ Agent Task Running
520
-
521
- **Task ID**: {task_id}
522
- **Agent**: {agent_type}
523
- **Description**: {description}
524
- **PID**: {pid}
525
- **Started**: {started}
526
-
527
- 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}"
528
764
 
529
765
  def get_progress(self, task_id: str, lines: int = 20) -> str:
530
- """
531
- Get real-time progress from a running agent's output.
532
-
533
- Args:
534
- task_id: The task ID
535
- lines: Number of lines to show from the end
536
-
537
- Returns:
538
- Recent output lines and status
539
- """
540
766
  task = self.get_task(task_id)
541
- if not task:
542
- return f"Task {task_id} not found."
543
-
767
+ if not task: return f"Task {task_id} not found."
544
768
  output_file = self.agents_dir / f"{task_id}.out"
545
- log_file = self.agents_dir / f"{task_id}.log"
546
-
547
- status = task["status"]
548
- description = task.get("description", "")
549
- agent_type = task.get("agent_type", "unknown")
550
- pid = task.get("pid")
551
-
552
- # Zombie Detection: If running but process is gone
553
- if status == "running" and pid:
554
- try:
555
- import psutil
556
-
557
- if not psutil.pid_exists(pid):
558
- status = "failed"
559
- self._update_task(
560
- task_id,
561
- status="failed",
562
- error="Agent process died unexpectedly (Zombie detected)",
563
- completed_at=datetime.now().isoformat(),
564
- )
565
- logger.warning(f"[AgentManager] Zombie agent detected: {task_id}")
566
- except ImportError:
567
- pass
568
-
569
- # Read recent output
570
769
  output_content = ""
571
770
  if output_file.exists():
572
771
  try:
573
- full_content = output_file.read_text()
574
- if full_content:
575
- output_lines = full_content.strip().split("\n")
576
- recent = output_lines[-lines:] if len(output_lines) > lines else output_lines
577
- output_content = "\n".join(recent)
578
- except Exception:
579
- pass
580
-
581
- # Check log for errors
582
- log_content = ""
583
- if log_file.exists():
584
- try:
585
- log_content = log_file.read_text().strip()
586
- except Exception:
587
- pass
588
-
589
- # Status emoji
590
- status_emoji = {
591
- "pending": "⏳",
592
- "running": "🔄",
593
- "completed": "✅",
594
- "failed": "❌",
595
- "cancelled": "⚠️",
596
- }.get(status, "❓")
597
-
598
- result = f"""{status_emoji} **Agent Progress**
599
-
600
- **Task ID**: {task_id}
601
- **Agent**: {agent_type}
602
- **Description**: {description}
603
- **Status**: {status}
604
- """
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```"
605
776
 
606
- if output_content:
607
- result += f"\n**Recent Output** (last {lines} lines):\n```\n{output_content}\n```"
608
- elif status == "running":
609
- result += "\n*Agent is working... no output yet.*"
610
777
 
611
- if log_content and status == "failed":
612
- # Truncate log if too long
613
- if len(log_content) > 500:
614
- log_content = log_content[:500] + "..."
615
- result += f"\n\n**Error Log**:\n```\n{log_content}\n```"
616
-
617
- return result
618
-
619
-
620
- # Global manager instance
621
- _manager: Optional[AgentManager] = None
778
+ _manager: AgentManager | None = None
622
779
  _manager_lock = threading.Lock()
623
780
 
624
-
625
781
  def get_manager() -> AgentManager:
626
- """Get or create the global AgentManager instance."""
627
782
  global _manager
628
783
  if _manager is None:
629
784
  with _manager_lock:
630
- # Double-check pattern to avoid race condition
631
785
  if _manager is None:
632
786
  _manager = AgentManager()
633
787
  return _manager
634
788
 
635
789
 
636
- # Tool interface functions
637
-
638
-
639
790
  async def agent_spawn(
640
791
  prompt: str,
641
792
  agent_type: str = "explore",
642
793
  description: str = "",
794
+ delegation_reason: str | None = None,
795
+ expected_outcome: str | None = None,
796
+ required_tools: list[str] | None = None,
643
797
  model: str = "gemini-3-flash",
644
798
  thinking_budget: int = 0,
645
799
  timeout: int = 300,
646
800
  blocking: bool = False,
801
+ spawning_agent: str | None = None,
802
+ semantic_first: bool = False,
647
803
  ) -> str:
648
- """
649
- Spawn a background agent.
650
-
651
- Args:
652
- prompt: The task for the agent to perform
653
- agent_type: Type of agent (explore, dewey, frontend, delphi)
654
- description: Short description shown in status
655
- model: Model to use (gemini-3-flash, gemini-2.0-flash, claude)
656
- thinking_budget: Reserved reasoning tokens
657
- timeout: Execution timeout in seconds
658
- blocking: If True, wait for completion and return result directly (use for delphi)
659
-
660
- Returns:
661
- Task ID and instructions, or full result if blocking=True
662
- """
663
804
  manager = get_manager()
664
-
665
- # Map agent types to system prompts
666
- # ALL agents use invoke_gemini or invoke_openai - NOT Claude directly
667
- # explore/dewey/document_writer/multimodal/frontend gemini-3-flash
668
- # delphi openai gpt-5.2
669
- system_prompts = {
670
- "explore": """You are a codebase exploration specialist. Find files, patterns, and answer 'where is X?' questions.
671
-
672
- MODEL ROUTING (MANDATORY):
673
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis and reasoning.
674
- Use Claude's native tools (Read, Grep, Glob) ONLY for file access, then pass content to invoke_gemini.
675
-
676
- WORKFLOW:
677
- 1. Use Read/Grep/Glob to get file contents
678
- 2. Call invoke_gemini(prompt="Analyze this: <content>", model="gemini-3-flash", agent_context={"agent_type": "explore"}) for analysis
679
- 3. Return the Gemini response""",
680
- "dewey": """You are a documentation and research specialist. Find implementation examples and official docs.
681
-
682
- MODEL ROUTING (MANDATORY):
683
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL analysis, summarization, and reasoning.
684
-
685
- WORKFLOW:
686
- 1. Gather information using available tools
687
- 2. Call invoke_gemini(prompt="<task>", model="gemini-3-flash", agent_context={"agent_type": "dewey"}) for processing
688
- 3. Return the Gemini response""",
689
- "frontend": """You are a Senior Frontend Architect & UI Designer.
690
-
691
- MODEL ROUTING (MANDATORY):
692
- You MUST use invoke_gemini with model="gemini-3-pro-high" for ALL code generation and design work.
693
-
694
- DESIGN PHILOSOPHY:
695
- - Anti-Generic: Reject standard layouts. Bespoke, asymmetric, distinctive.
696
- - Library Discipline: Use existing UI libraries (Shadcn, Radix, MUI) if detected.
697
- - Stack: React/Vue/Svelte, Tailwind/Custom CSS, semantic HTML5.
698
-
699
- WORKFLOW:
700
- 1. Analyze requirements
701
- 2. Call invoke_gemini(prompt="Generate frontend code for: <task>", model="gemini-3-pro-high", agent_context={"agent_type": "frontend"})
702
- 3. Return the code""",
703
- "delphi": """You are a strategic technical advisor for architecture and hard debugging.
704
-
705
- MODEL ROUTING (MANDATORY):
706
- You MUST use invoke_openai with model="gpt-5.2" for ALL strategic advice and analysis.
707
-
708
- WORKFLOW:
709
- 1. Gather context about the problem
710
- 2. Call invoke_openai(prompt="<problem description>", model="gpt-5.2", agent_context={"agent_type": "delphi"})
711
- 3. Return the GPT response""",
712
- "document_writer": """You are a Technical Documentation Specialist.
713
-
714
- MODEL ROUTING (MANDATORY):
715
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL documentation generation.
716
-
717
- DOCUMENT TYPES: README, API docs, ADRs, user guides, inline docs.
718
-
719
- WORKFLOW:
720
- 1. Gather context about what to document
721
- 2. Call invoke_gemini(prompt="Write documentation for: <topic>", model="gemini-3-flash", agent_context={"agent_type": "document_writer"})
722
- 3. Return the documentation""",
723
- "multimodal": """You interpret media files (PDFs, images, diagrams, screenshots).
724
-
725
- MODEL ROUTING (MANDATORY):
726
- You MUST use invoke_gemini with model="gemini-3-flash" for ALL visual analysis.
727
-
728
- WORKFLOW:
729
- 1. Receive file path and extraction goal
730
- 2. Call invoke_gemini(prompt="Analyze this file: <path>. Extract: <goal>", model="gemini-3-flash", agent_context={"agent_type": "multimodal"})
731
- 3. Return extracted information only""",
732
- "planner": """You are a pre-implementation planning specialist. You analyze requests and produce structured implementation plans BEFORE any code changes begin.
733
-
734
- PURPOSE:
735
- - Analyze requests and produce actionable implementation plans
736
- - Identify dependencies and parallelization opportunities
737
- - Enable efficient parallel execution by the orchestrator
738
- - Prevent wasted effort through upfront planning
739
-
740
- METHODOLOGY:
741
- 1. EXPLORE FIRST: Spawn explore agents IN PARALLEL to understand the codebase
742
- 2. DECOMPOSE: Break request into atomic, single-purpose tasks
743
- 3. ANALYZE DEPENDENCIES: What blocks what? What can run in parallel?
744
- 4. ASSIGN AGENTS: Map each task to the right specialist (explore/dewey/frontend/delphi)
745
- 5. OUTPUT STRUCTURED PLAN: Use the required format below
746
-
747
- REQUIRED OUTPUT FORMAT:
748
- ```
749
- ## PLAN: [Brief title]
750
-
751
- ### ANALYSIS
752
- - **Request**: [One sentence summary]
753
- - **Scope**: [What's in/out of scope]
754
- - **Risk Level**: [Low/Medium/High]
755
-
756
- ### EXECUTION PHASES
757
-
758
- #### Phase 1: [Name] (PARALLEL)
759
- | Task | Agent | Files | Est |
760
- |------|-------|-------|-----|
761
- | [description] | explore | file.py | S/M/L |
762
-
763
- #### Phase 2: [Name] (SEQUENTIAL after Phase 1)
764
- | Task | Agent | Files | Est |
765
- |------|-------|-------|-----|
766
-
767
- ### AGENT SPAWN COMMANDS
768
- ```python
769
- # Phase 1 - Fire all in parallel
770
- agent_spawn(prompt="...", agent_type="explore", description="...")
771
- ```
772
- ```
773
-
774
- CONSTRAINTS:
775
- - You ONLY plan. You NEVER execute code changes.
776
- - Every task must have a clear agent assignment
777
- - Parallel phases must be truly independent
778
- - Include ready-to-use agent_spawn commands""",
779
- }
780
-
781
- system_prompt = system_prompts.get(agent_type, None)
782
-
783
- # Model routing (MANDATORY - enforced in system prompts):
784
- # - explore, dewey, document_writer, multimodal → invoke_gemini(gemini-3-flash)
785
- # - frontend → invoke_gemini(gemini-3-pro-high)
786
- # - delphi → invoke_openai(gpt-5.2)
787
- # - Unknown agent types (coding tasks) → Claude CLI --model sonnet
788
-
789
- # 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."
790
811
  from ..auth.token_store import TokenStore
791
-
792
812
  token_store = TokenStore()
793
-
794
- task_id = manager.spawn(
813
+ task_id = await manager.spawn_async(
795
814
  token_store=token_store,
796
815
  prompt=prompt,
797
816
  agent_type=agent_type,
798
- description=description or prompt[:50],
817
+ description=description,
799
818
  system_prompt=system_prompt,
800
- model=model, # Not used for Claude CLI, kept for API compatibility
801
- thinking_budget=thinking_budget, # Not used for Claude CLI, kept for API compatibility
802
819
  timeout=timeout,
820
+ semantic_first=semantic_first,
803
821
  )
804
-
805
- # Get display model for concise output
806
- display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
807
- short_desc = (description or prompt[:50]).strip()
808
-
809
- # 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
810
825
  if blocking:
811
- result = manager.get_output(task_id, block=True, timeout=timeout)
812
- return f"{agent_type}:{display_model}('{short_desc}') [BLOCKING]\n\n{result}"
813
-
814
- # Concise format: AgentType:model('description')
815
- return f"""{agent_type}:{display_model}('{short_desc}')
816
- task_id={task_id}"""
817
-
818
-
819
- async def agent_output(task_id: str, block: bool = False) -> str:
820
- """
821
- 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)
822
829
 
823
- Args:
824
- task_id: The task ID from agent_spawn
825
- block: If True, wait for the task to complete (up to 30s)
826
830
 
827
- Returns:
828
- Task status and output
829
- """
831
+ async def agent_output(task_id: str, block: bool = False, auto_cleanup: bool = False) -> str:
830
832
  manager = get_manager()
831
- return manager.get_output(task_id, block=block)
832
-
833
+ return await manager.get_output(task_id, block=block, auto_cleanup=auto_cleanup)
833
834
 
834
- async def agent_retry(
835
- task_id: str,
836
- new_prompt: Optional[str] = None,
837
- new_timeout: Optional[int] = None,
838
- ) -> str:
839
- """
840
- Retry a failed or timed-out background agent.
841
-
842
- Args:
843
- task_id: The ID of the task to retry
844
- new_prompt: Optional refined prompt for the retry
845
- new_timeout: Optional new timeout in seconds
846
-
847
- Returns:
848
- New Task ID and status
849
- """
835
+ async def agent_retry(task_id: str, new_prompt: str = None, new_timeout: int = None) -> str:
850
836
  manager = get_manager()
851
837
  task = manager.get_task(task_id)
852
-
853
- if not task:
854
- return f"❌ Task {task_id} not found."
855
-
856
- if task["status"] in ["running", "pending"]:
857
- return f"⚠️ Task {task_id} is still {task['status']}. Cancel it first if you want to retry."
858
-
859
- prompt = new_prompt or task["prompt"]
860
- timeout = new_timeout or task.get("timeout", 300)
861
-
862
- return await agent_spawn(
863
- prompt=prompt,
864
- agent_type=task["agent_type"],
865
- description=f"Retry of {task_id}: {task['description']}",
866
- timeout=timeout,
867
- )
868
-
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"])
869
840
 
870
841
  async def agent_cancel(task_id: str) -> str:
871
- """
872
- Cancel a running background agent.
873
-
874
- Args:
875
- task_id: The task ID to cancel
876
-
877
- Returns:
878
- Cancellation result
879
- """
880
842
  manager = get_manager()
881
- success = manager.cancel(task_id)
882
-
883
- if success:
884
- return f"✅ Agent task {task_id} has been cancelled."
885
- else:
886
- task = manager.get_task(task_id)
887
- if not task:
888
- return f"❌ Task {task_id} not found."
889
- else:
890
- return f"⚠️ Task {task_id} is not running (status: {task['status']}). Cannot cancel."
891
-
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}."
892
846
 
893
- async def agent_list() -> str:
894
- """
895
- List all background agent tasks.
896
-
897
- Returns:
898
- Formatted list of tasks
899
- """
847
+ async def agent_cleanup(max_age_minutes: int = 30, statuses: list[str] = None) -> str:
900
848
  manager = get_manager()
901
- tasks = manager.list_tasks()
902
-
903
- if not tasks:
904
- return "No background agent tasks found."
905
-
906
- lines = []
907
-
908
- for t in sorted(tasks, key=lambda x: x.get("created_at", ""), reverse=True):
909
- status_emoji = {
910
- "pending": "⏳",
911
- "running": "🔄",
912
- "completed": "✅",
913
- "failed": "❌",
914
- "cancelled": "⚠️",
915
- }.get(t["status"], "❓")
916
-
917
- agent_type = t.get("agent_type", "unknown")
918
- display_model = AGENT_DISPLAY_MODELS.get(agent_type, AGENT_DISPLAY_MODELS["_default"])
919
- desc = t.get("description", t.get("prompt", "")[:40])
920
- # Concise format: status agent:model('desc') id=xxx
921
- lines.append(f"{status_emoji} {agent_type}:{display_model}('{desc}') id={t['id']}")
922
-
923
- return "\n".join(lines)
849
+ res = manager.cleanup(max_age_minutes, statuses)
850
+ return res["summary"]
924
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])
925
857
 
926
858
  async def agent_progress(task_id: str, lines: int = 20) -> str:
927
- """
928
- Get real-time progress from a running background agent.
929
-
930
- Shows the most recent output lines from the agent, useful for
931
- monitoring what the agent is currently doing.
932
-
933
- Args:
934
- task_id: The task ID from agent_spawn
935
- lines: Number of recent output lines to show (default 20)
936
-
937
- Returns:
938
- Recent agent output and status
939
- """
940
859
  manager = get_manager()
941
- return manager.get_progress(task_id, lines=lines)
860
+ return manager.get_progress(task_id, lines)