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.
- mcp_bridge/__init__.py +1 -1
- mcp_bridge/auth/__init__.py +16 -6
- mcp_bridge/auth/cli.py +202 -11
- mcp_bridge/auth/oauth.py +1 -2
- mcp_bridge/auth/openai_oauth.py +4 -7
- mcp_bridge/auth/token_store.py +0 -1
- mcp_bridge/cli/__init__.py +1 -1
- mcp_bridge/cli/install_hooks.py +503 -107
- mcp_bridge/cli/session_report.py +0 -3
- mcp_bridge/config/__init__.py +2 -2
- mcp_bridge/config/hook_config.py +3 -5
- mcp_bridge/config/rate_limits.py +108 -13
- mcp_bridge/hooks/HOOKS_SETTINGS.json +17 -4
- mcp_bridge/hooks/__init__.py +14 -4
- mcp_bridge/hooks/agent_reminder.py +4 -4
- mcp_bridge/hooks/auto_slash_command.py +5 -5
- mcp_bridge/hooks/budget_optimizer.py +2 -2
- mcp_bridge/hooks/claude_limits_hook.py +114 -0
- mcp_bridge/hooks/comment_checker.py +3 -4
- mcp_bridge/hooks/compaction.py +2 -2
- mcp_bridge/hooks/context.py +2 -1
- mcp_bridge/hooks/context_monitor.py +2 -2
- mcp_bridge/hooks/delegation_policy.py +85 -0
- mcp_bridge/hooks/directory_context.py +3 -3
- mcp_bridge/hooks/edit_recovery.py +3 -2
- mcp_bridge/hooks/edit_recovery_policy.py +49 -0
- mcp_bridge/hooks/empty_message_sanitizer.py +2 -2
- mcp_bridge/hooks/events.py +160 -0
- mcp_bridge/hooks/git_noninteractive.py +4 -4
- mcp_bridge/hooks/keyword_detector.py +8 -10
- mcp_bridge/hooks/manager.py +35 -22
- mcp_bridge/hooks/notification_hook.py +13 -6
- mcp_bridge/hooks/parallel_enforcement_policy.py +67 -0
- mcp_bridge/hooks/parallel_enforcer.py +5 -5
- mcp_bridge/hooks/parallel_execution.py +22 -10
- mcp_bridge/hooks/post_tool/parallel_validation.py +103 -0
- mcp_bridge/hooks/pre_compact.py +8 -9
- mcp_bridge/hooks/pre_tool/agent_spawn_validator.py +115 -0
- mcp_bridge/hooks/preemptive_compaction.py +2 -3
- mcp_bridge/hooks/routing_notifications.py +80 -0
- mcp_bridge/hooks/rules_injector.py +11 -19
- mcp_bridge/hooks/session_idle.py +4 -4
- mcp_bridge/hooks/session_notifier.py +4 -4
- mcp_bridge/hooks/session_recovery.py +4 -5
- mcp_bridge/hooks/stravinsky_mode.py +1 -1
- mcp_bridge/hooks/subagent_stop.py +1 -3
- mcp_bridge/hooks/task_validator.py +2 -2
- mcp_bridge/hooks/tmux_manager.py +7 -8
- mcp_bridge/hooks/todo_delegation.py +4 -1
- mcp_bridge/hooks/todo_enforcer.py +180 -10
- mcp_bridge/hooks/truncation_policy.py +37 -0
- mcp_bridge/hooks/truncator.py +1 -2
- mcp_bridge/metrics/cost_tracker.py +115 -0
- mcp_bridge/native_search.py +93 -0
- mcp_bridge/native_watcher.py +118 -0
- mcp_bridge/notifications.py +3 -4
- mcp_bridge/orchestrator/enums.py +11 -0
- mcp_bridge/orchestrator/router.py +165 -0
- mcp_bridge/orchestrator/state.py +32 -0
- mcp_bridge/orchestrator/visualization.py +14 -0
- mcp_bridge/orchestrator/wisdom.py +34 -0
- mcp_bridge/prompts/__init__.py +1 -8
- mcp_bridge/prompts/dewey.py +1 -1
- mcp_bridge/prompts/planner.py +2 -4
- mcp_bridge/prompts/stravinsky.py +53 -31
- mcp_bridge/proxy/__init__.py +0 -0
- mcp_bridge/proxy/client.py +70 -0
- mcp_bridge/proxy/model_server.py +157 -0
- mcp_bridge/routing/__init__.py +43 -0
- mcp_bridge/routing/config.py +250 -0
- mcp_bridge/routing/model_tiers.py +135 -0
- mcp_bridge/routing/provider_state.py +261 -0
- mcp_bridge/routing/task_classifier.py +190 -0
- mcp_bridge/server.py +363 -34
- mcp_bridge/server_tools.py +298 -6
- mcp_bridge/tools/__init__.py +19 -8
- mcp_bridge/tools/agent_manager.py +549 -799
- mcp_bridge/tools/background_tasks.py +13 -17
- mcp_bridge/tools/code_search.py +54 -51
- mcp_bridge/tools/continuous_loop.py +0 -1
- mcp_bridge/tools/dashboard.py +19 -0
- mcp_bridge/tools/find_code.py +296 -0
- mcp_bridge/tools/init.py +1 -0
- mcp_bridge/tools/list_directory.py +42 -0
- mcp_bridge/tools/lsp/__init__.py +8 -8
- mcp_bridge/tools/lsp/manager.py +51 -28
- mcp_bridge/tools/lsp/tools.py +98 -65
- mcp_bridge/tools/model_invoke.py +1047 -152
- mcp_bridge/tools/mux_client.py +75 -0
- mcp_bridge/tools/project_context.py +1 -2
- mcp_bridge/tools/query_classifier.py +132 -49
- mcp_bridge/tools/read_file.py +84 -0
- mcp_bridge/tools/replace.py +45 -0
- mcp_bridge/tools/run_shell_command.py +38 -0
- mcp_bridge/tools/search_enhancements.py +347 -0
- mcp_bridge/tools/semantic_search.py +677 -92
- mcp_bridge/tools/session_manager.py +0 -2
- mcp_bridge/tools/skill_loader.py +0 -1
- mcp_bridge/tools/task_runner.py +5 -7
- mcp_bridge/tools/templates.py +3 -3
- mcp_bridge/tools/tool_search.py +331 -0
- mcp_bridge/tools/write_file.py +29 -0
- mcp_bridge/update_manager.py +33 -37
- mcp_bridge/update_manager_pypi.py +6 -8
- mcp_bridge/utils/cache.py +82 -0
- mcp_bridge/utils/process.py +71 -0
- mcp_bridge/utils/session_state.py +51 -0
- mcp_bridge/utils/truncation.py +76 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/METADATA +84 -35
- stravinsky-0.4.66.dist-info/RECORD +198 -0
- {stravinsky-0.4.18.dist-info → stravinsky-0.4.66.dist-info}/entry_points.txt +1 -0
- stravinsky_claude_assets/HOOKS_INTEGRATION.md +316 -0
- stravinsky_claude_assets/agents/HOOKS.md +437 -0
- stravinsky_claude_assets/agents/code-reviewer.md +210 -0
- stravinsky_claude_assets/agents/comment_checker.md +580 -0
- stravinsky_claude_assets/agents/debugger.md +254 -0
- stravinsky_claude_assets/agents/delphi.md +495 -0
- stravinsky_claude_assets/agents/dewey.md +248 -0
- stravinsky_claude_assets/agents/explore.md +1198 -0
- stravinsky_claude_assets/agents/frontend.md +472 -0
- stravinsky_claude_assets/agents/implementation-lead.md +164 -0
- stravinsky_claude_assets/agents/momus.md +464 -0
- stravinsky_claude_assets/agents/research-lead.md +141 -0
- stravinsky_claude_assets/agents/stravinsky.md +730 -0
- stravinsky_claude_assets/commands/delphi.md +9 -0
- stravinsky_claude_assets/commands/dewey.md +54 -0
- stravinsky_claude_assets/commands/git-master.md +112 -0
- stravinsky_claude_assets/commands/index.md +49 -0
- stravinsky_claude_assets/commands/publish.md +86 -0
- stravinsky_claude_assets/commands/review.md +73 -0
- stravinsky_claude_assets/commands/str/agent_cancel.md +70 -0
- stravinsky_claude_assets/commands/str/agent_list.md +56 -0
- stravinsky_claude_assets/commands/str/agent_output.md +92 -0
- stravinsky_claude_assets/commands/str/agent_progress.md +74 -0
- stravinsky_claude_assets/commands/str/agent_retry.md +94 -0
- stravinsky_claude_assets/commands/str/cancel.md +51 -0
- stravinsky_claude_assets/commands/str/clean.md +97 -0
- stravinsky_claude_assets/commands/str/continue.md +38 -0
- stravinsky_claude_assets/commands/str/index.md +199 -0
- stravinsky_claude_assets/commands/str/list_watchers.md +96 -0
- stravinsky_claude_assets/commands/str/search.md +205 -0
- stravinsky_claude_assets/commands/str/start_filewatch.md +136 -0
- stravinsky_claude_assets/commands/str/stats.md +71 -0
- stravinsky_claude_assets/commands/str/stop_filewatch.md +89 -0
- stravinsky_claude_assets/commands/str/unwatch.md +42 -0
- stravinsky_claude_assets/commands/str/watch.md +45 -0
- stravinsky_claude_assets/commands/strav.md +53 -0
- stravinsky_claude_assets/commands/stravinsky.md +292 -0
- stravinsky_claude_assets/commands/verify.md +60 -0
- stravinsky_claude_assets/commands/version.md +5 -0
- stravinsky_claude_assets/hooks/README.md +248 -0
- stravinsky_claude_assets/hooks/comment_checker.py +193 -0
- stravinsky_claude_assets/hooks/context.py +38 -0
- stravinsky_claude_assets/hooks/context_monitor.py +153 -0
- stravinsky_claude_assets/hooks/dependency_tracker.py +73 -0
- stravinsky_claude_assets/hooks/edit_recovery.py +46 -0
- stravinsky_claude_assets/hooks/execution_state_tracker.py +68 -0
- stravinsky_claude_assets/hooks/notification_hook.py +103 -0
- stravinsky_claude_assets/hooks/notification_hook_v2.py +96 -0
- stravinsky_claude_assets/hooks/parallel_execution.py +241 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement.py +106 -0
- stravinsky_claude_assets/hooks/parallel_reinforcement_v2.py +112 -0
- stravinsky_claude_assets/hooks/pre_compact.py +123 -0
- stravinsky_claude_assets/hooks/ralph_loop.py +173 -0
- stravinsky_claude_assets/hooks/session_recovery.py +263 -0
- stravinsky_claude_assets/hooks/stop_hook.py +89 -0
- stravinsky_claude_assets/hooks/stravinsky_metrics.py +164 -0
- stravinsky_claude_assets/hooks/stravinsky_mode.py +146 -0
- stravinsky_claude_assets/hooks/subagent_stop.py +98 -0
- stravinsky_claude_assets/hooks/todo_continuation.py +111 -0
- stravinsky_claude_assets/hooks/todo_delegation.py +96 -0
- stravinsky_claude_assets/hooks/tool_messaging.py +281 -0
- stravinsky_claude_assets/hooks/truncator.py +23 -0
- stravinsky_claude_assets/rules/deployment_safety.md +51 -0
- stravinsky_claude_assets/rules/integration_wiring.md +89 -0
- stravinsky_claude_assets/rules/pypi_deployment.md +220 -0
- stravinsky_claude_assets/rules/stravinsky_orchestrator.md +32 -0
- stravinsky_claude_assets/settings.json +152 -0
- stravinsky_claude_assets/skills/chrome-devtools/SKILL.md +81 -0
- stravinsky_claude_assets/skills/sqlite/SKILL.md +77 -0
- stravinsky_claude_assets/skills/supabase/SKILL.md +74 -0
- stravinsky_claude_assets/task_dependencies.json +34 -0
- stravinsky-0.4.18.dist-info/RECORD +0 -88
- {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
|
|
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,
|
|
20
|
-
import
|
|
21
|
-
import
|
|
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,
|
|
40
|
-
"implementation-lead":
|
|
41
|
-
|
|
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",
|
|
50
|
-
"dewey": "CHEAP",
|
|
51
|
-
"document_writer": "CHEAP",
|
|
52
|
-
"multimodal": "CHEAP",
|
|
53
|
-
"research-lead": "CHEAP",
|
|
54
|
-
"implementation-lead": "
|
|
55
|
-
"
|
|
56
|
-
"
|
|
57
|
-
"
|
|
58
|
-
"
|
|
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": "
|
|
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": "🟢",
|
|
79
|
-
"MEDIUM": "🔵",
|
|
80
|
-
"EXPENSIVE": "🟣",
|
|
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
|
-
|
|
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
|
|
272
|
+
agent_type: str
|
|
171
273
|
description: str
|
|
172
|
-
status: str
|
|
274
|
+
status: str
|
|
173
275
|
created_at: str
|
|
174
|
-
parent_session_id:
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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:
|
|
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 / "
|
|
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
|
-
|
|
227
|
-
self.
|
|
228
|
-
self.
|
|
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) ->
|
|
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
|
|
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:
|
|
242
|
-
|
|
243
|
-
|
|
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) ->
|
|
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(
|
|
261
|
-
|
|
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
|
|
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:
|
|
277
|
-
system_prompt:
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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="
|
|
451
|
-
|
|
599
|
+
status="completed",
|
|
600
|
+
result=stdout.strip(),
|
|
452
601
|
completed_at=datetime.now().isoformat(),
|
|
453
602
|
)
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
613
|
+
except asyncio.CancelledError:
|
|
614
|
+
|
|
615
|
+
|
|
470
616
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
|
479
|
-
|
|
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
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
except
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
702
|
+
self._tasks.clear()
|
|
703
|
+
self._progress_monitors.clear()
|
|
544
704
|
return cleared
|
|
545
|
-
|
|
546
705
|
return stopped_count
|
|
547
706
|
|
|
548
|
-
def
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
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"]
|
|
565
|
-
|
|
566
|
-
|
|
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"]
|
|
570
|
-
|
|
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
|
-
|
|
587
|
-
return f"
|
|
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
|
-
|
|
597
|
-
return f"
|
|
598
|
-
|
|
599
|
-
|
|
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
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
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
|
-
|
|
958
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
860
|
+
return manager.get_progress(task_id, lines)
|